refactor: enhance settings and auth components for improved navigation and user feedback
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 54s
CI / go (push) Successful in 1m3s
CI / bird2 (push) Successful in 20s
CI / release (push) Successful in 4m29s
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 54s
CI / go (push) Successful in 1m3s
CI / bird2 (push) Successful in 20s
CI / release (push) Successful in 4m29s
Updated the Settings and Auth components to include better navigation handling by adding search parameters for tab selection. Enhanced the Access component to utilize badges for status indicators, improving visual clarity. Refactored the Schedule component to streamline job status display with badges, ensuring a more cohesive user experience. Additionally, integrated client directives in UI components for better performance.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Minus, TrendingDown, TrendingUp } from 'lucide-react'
|
||||
|
||||
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
|
||||
export type AnalyticsKpiItem = {
|
||||
@@ -12,39 +12,42 @@ export type AnalyticsKpiItem = {
|
||||
}
|
||||
}
|
||||
|
||||
const TONE_CLASS = {
|
||||
success: 'text-success',
|
||||
warning: 'text-warning',
|
||||
destructive: 'text-destructive',
|
||||
muted: 'text-muted-foreground',
|
||||
} as const
|
||||
|
||||
function DeltaIcon({ direction }: { direction: AnalyticsKpiItem['delta'] extends infer D ? D extends { direction: infer Dir } ? Dir : never : never }) {
|
||||
if (direction === 'up') return <TrendingUp className="size-3" />
|
||||
if (direction === 'down') return <TrendingDown className="size-3" />
|
||||
return <Minus className="size-3" />
|
||||
function deltaBadgeVariant(
|
||||
tone?: 'success' | 'warning' | 'destructive' | 'muted',
|
||||
): 'success-light' | 'warning-light' | 'destructive-light' | 'outline' {
|
||||
if (tone === 'success') return 'success-light'
|
||||
if (tone === 'warning') return 'warning-light'
|
||||
if (tone === 'destructive') return 'destructive-light'
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
export function AnalyticsKpiRow({ items, className }: { items: AnalyticsKpiItem[]; className?: string }) {
|
||||
function toKpiItem(item: AnalyticsKpiItem): KpiStatItem {
|
||||
return {
|
||||
id: item.label,
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
footer: item.delta ? (
|
||||
<Badge variant={deltaBadgeVariant(item.delta.tone)} size="sm">
|
||||
{item.delta.label}
|
||||
</Badge>
|
||||
) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact KPI row inside analytics panels (stats-12 embedded tiles). */
|
||||
export function AnalyticsKpiRow({
|
||||
items,
|
||||
className,
|
||||
}: {
|
||||
items: AnalyticsKpiItem[]
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('grid gap-4 sm:grid-cols-3', className)}>
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="min-w-0 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{item.label}</p>
|
||||
<p className="text-2xl font-semibold tracking-tight tabular-nums">{item.value}</p>
|
||||
{item.delta ? (
|
||||
<p
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-xs',
|
||||
TONE_CLASS[item.delta.tone ?? 'muted'],
|
||||
)}
|
||||
>
|
||||
<DeltaIcon direction={item.delta.direction} />
|
||||
{item.delta.label}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
items={items.map(toKpiItem)}
|
||||
embedded
|
||||
className={cn(className)}
|
||||
aria-label="Показатели"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,14 +40,12 @@ export function BadgeTabs({
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={onValueChange}
|
||||
orientation="horizontal"
|
||||
className={cn('w-full', className)}
|
||||
>
|
||||
<TabsList
|
||||
variant="line"
|
||||
className={cn(
|
||||
'mb-4 w-full justify-start gap-6',
|
||||
listClassName,
|
||||
)}
|
||||
className={cn('mb-3.5 w-full justify-start gap-6', listClassName)}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<TabsTrigger key={item.value} value={item.value} className="gap-2">
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import { useState, type ComponentType } from "react"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@evobgp/ui/components/tabs"
|
||||
|
||||
import { BillingTab } from "./billing-tab"
|
||||
import { SETTINGS_TAB_ITEMS } from "./data"
|
||||
import { NotificationsTab } from "./notifications-tab"
|
||||
import { ProfileTab } from "./profile-tab"
|
||||
import { SecurityTab } from "./security-tab"
|
||||
|
||||
const TAB_COMPONENTS: Record<string, ComponentType> = {
|
||||
profile: ProfileTab,
|
||||
security: SecurityTab,
|
||||
notifications: NotificationsTab,
|
||||
billing: BillingTab,
|
||||
}
|
||||
|
||||
// ── Settings Navigation ──
|
||||
|
||||
function SettingsNavigation({
|
||||
isMobile,
|
||||
activeValue,
|
||||
}: {
|
||||
isMobile: boolean
|
||||
activeValue: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("min-w-0", isMobile ? "w-full" : "w-40 shrink-0")}>
|
||||
{isMobile ? (
|
||||
<div className="-mx-1 overflow-x-auto px-1 pb-1">
|
||||
<TabsList className="h-auto w-max min-w-max justify-start gap-1 bg-transparent p-0">
|
||||
{SETTINGS_TAB_ITEMS.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 px-3 py-1.5 shadow-none",
|
||||
activeValue === tab.value ? "bg-muted!" : "bg-transparent"
|
||||
)}
|
||||
>
|
||||
{tab.icon}
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
) : (
|
||||
<TabsList className="h-auto w-full flex-col items-stretch gap-1 bg-transparent p-0">
|
||||
{SETTINGS_TAB_ITEMS.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 px-3 py-1.5 shadow-none",
|
||||
activeValue === tab.value ? "bg-muted!" : "bg-transparent"
|
||||
)}
|
||||
>
|
||||
{tab.icon}
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AccountSettings() {
|
||||
const isMobile = useIsMobile()
|
||||
const [activeTab, setActiveTab] = useState("profile")
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl space-y-8">
|
||||
{/* Header */}
|
||||
<header className="px-1">
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
Account Settings
|
||||
</h1>
|
||||
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
|
||||
Update your profile, access, notifications, and billing preferences.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
orientation={isMobile ? "horizontal" : "vertical"}
|
||||
className={cn("w-full gap-4 lg:gap-8")}
|
||||
>
|
||||
<SettingsNavigation isMobile={isMobile} activeValue={activeTab} />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{SETTINGS_TAB_ITEMS.map((tab) => {
|
||||
const TabComponent = TAB_COMPONENTS[tab.value]
|
||||
|
||||
return (
|
||||
<TabsContent key={tab.value} value={tab.value} className="mt-0">
|
||||
<TabComponent />
|
||||
</TabsContent>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@evobgp/ui/components/field"
|
||||
import { Input } from "@evobgp/ui/components/input"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@evobgp/ui/components/input-group"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
|
||||
import { BILLING_PLANS, COUNTRIES } from "./data"
|
||||
import { SettingRow } from "./setting-row"
|
||||
import { SettingsCard } from "./settings-card"
|
||||
import { SettingsFieldGroup } from "./settings-field-group"
|
||||
import { createSelectValueHandler, getOptionLabel } from "./utils"
|
||||
|
||||
export function BillingTab() {
|
||||
const [country, setCountry] = useState("us")
|
||||
const handleCountryChange = createSelectValueHandler(setCountry)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Card */}
|
||||
<SettingsCard title="Plan and billing" description="Current subscription">
|
||||
<SettingsFieldGroup
|
||||
legend="Plan and billing"
|
||||
description="Review your current subscription and plan options."
|
||||
>
|
||||
{BILLING_PLANS.map((plan, index) => (
|
||||
<SettingRow
|
||||
key={plan.id}
|
||||
title={plan.name}
|
||||
titleAddon={
|
||||
plan.current ? (
|
||||
<Badge variant="info-light" size="sm">
|
||||
Current
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
description={
|
||||
<>
|
||||
<span className="text-foreground font-medium">
|
||||
{plan.price}
|
||||
</span>{" "}
|
||||
/ {plan.period} · {plan.features.join(", ")}
|
||||
</>
|
||||
}
|
||||
last={index === BILLING_PLANS.length - 1}
|
||||
>
|
||||
<Button variant={plan.current ? "outline" : "ghost"} size="sm">
|
||||
{plan.current ? "Manage" : "Switch"}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
))}
|
||||
</SettingsFieldGroup>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Billing details"
|
||||
description="Invoices and payments"
|
||||
footer={<Button>Update billing</Button>}
|
||||
>
|
||||
<SettingsFieldGroup
|
||||
legend="Billing details"
|
||||
description="Manage invoice contacts and payment details."
|
||||
>
|
||||
<SettingRow
|
||||
title="Billing email"
|
||||
description="Receives invoices and renewal notices."
|
||||
labelFor="settings-7-billing-email"
|
||||
>
|
||||
<Input
|
||||
id="settings-7-billing-email"
|
||||
defaultValue="[email protected]"
|
||||
type="email"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Invoice profile"
|
||||
description="Business details used on receipts and tax forms."
|
||||
contentClassName="@md/field-group:w-[22rem]"
|
||||
>
|
||||
<FieldSet className="w-full gap-3">
|
||||
<FieldLegend className="sr-only">Invoice profile</FieldLegend>
|
||||
<FieldDescription className="sr-only">
|
||||
Company identity used for invoices and tax documents.
|
||||
</FieldDescription>
|
||||
|
||||
<FieldGroup className="gap-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-7-company-name">
|
||||
Company name
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="settings-7-company-name"
|
||||
defaultValue="Acme Labs"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-7-billing-country">
|
||||
Country
|
||||
</FieldLabel>
|
||||
<Select value={country} onValueChange={handleCountryChange}>
|
||||
<SelectTrigger
|
||||
id="settings-7-billing-country"
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue>
|
||||
{getOptionLabel(COUNTRIES, country)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{COUNTRIES.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Tax ID"
|
||||
description="Optional number used for VAT or company invoices."
|
||||
labelFor="settings-7-tax-id"
|
||||
>
|
||||
<InputGroup className="w-full">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<InputGroupText>VAT</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="settings-7-tax-id"
|
||||
defaultValue="US-2048-ACME"
|
||||
/>
|
||||
</InputGroup>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Card on file"
|
||||
description="Used for monthly renewals."
|
||||
last
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Visa ending in 4242
|
||||
</span>
|
||||
<Button variant="outline">Update</Button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</SettingsFieldGroup>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client"
|
||||
|
||||
import { type ReactNode } from "react"
|
||||
import { UserIcon, ShieldIcon, BellIcon, CreditCardIcon, MonitorIcon, SmartphoneIcon } from "lucide-react"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export type SelectOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type BillingPlan = {
|
||||
id: string
|
||||
name: string
|
||||
price: string
|
||||
period: string
|
||||
current: boolean
|
||||
features: string[]
|
||||
}
|
||||
|
||||
export type Session = {
|
||||
id: string
|
||||
device: string
|
||||
browser: string
|
||||
location: string
|
||||
lastActive: string
|
||||
current: boolean
|
||||
icon: ReactNode
|
||||
}
|
||||
|
||||
export type SettingsTabItem = {
|
||||
value: string
|
||||
label: string
|
||||
icon: ReactNode
|
||||
}
|
||||
|
||||
// ── Data ──
|
||||
|
||||
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
|
||||
{
|
||||
value: "profile",
|
||||
label: "My Profile",
|
||||
icon: (
|
||||
<UserIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "security",
|
||||
label: "Security",
|
||||
icon: (
|
||||
<ShieldIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "notifications",
|
||||
label: "Notifications",
|
||||
icon: (
|
||||
<BellIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "billing",
|
||||
label: "Billing",
|
||||
icon: (
|
||||
<CreditCardIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export const TIMEZONES: SelectOption[] = [
|
||||
{ value: "utc-8", label: "UTC-8 (Pacific Time)" },
|
||||
{ value: "utc-5", label: "UTC-5 (Eastern Time)" },
|
||||
{ value: "utc+0", label: "UTC+0 (London)" },
|
||||
{ value: "utc+1", label: "UTC+1 (Berlin)" },
|
||||
{ value: "utc+9", label: "UTC+9 (Tokyo)" },
|
||||
]
|
||||
|
||||
export const ROLES: SelectOption[] = [
|
||||
{ value: "engineering-lead", label: "Engineering Lead" },
|
||||
{ value: "developer", label: "Developer" },
|
||||
{ value: "designer", label: "Designer" },
|
||||
{ value: "product-manager", label: "Product Manager" },
|
||||
]
|
||||
|
||||
export const COUNTRIES: SelectOption[] = [
|
||||
{ value: "us", label: "United States" },
|
||||
{ value: "uk", label: "United Kingdom" },
|
||||
{ value: "de", label: "Germany" },
|
||||
{ value: "uz", label: "Uzbekistan" },
|
||||
]
|
||||
|
||||
export const TIMEOUTS: SelectOption[] = [
|
||||
{ value: "5", label: "5 minutes" },
|
||||
{ value: "10", label: "10 minutes" },
|
||||
{ value: "15", label: "15 minutes" },
|
||||
{ value: "30", label: "30 minutes" },
|
||||
]
|
||||
|
||||
export const QUIET_HOURS: SelectOption[] = [
|
||||
{ value: "18:00", label: "6:00 PM" },
|
||||
{ value: "20:00", label: "8:00 PM" },
|
||||
{ value: "22:00", label: "10:00 PM" },
|
||||
{ value: "23:00", label: "11:00 PM" },
|
||||
]
|
||||
|
||||
export const QUIET_HOURS_END: SelectOption[] = [
|
||||
{ value: "06:00", label: "6:00 AM" },
|
||||
{ value: "08:00", label: "8:00 AM" },
|
||||
{ value: "09:00", label: "9:00 AM" },
|
||||
{ value: "10:00", label: "10:00 AM" },
|
||||
]
|
||||
|
||||
export const DIGEST_CADENCE: SelectOption[] = [
|
||||
{ value: "daily", label: "Daily summary" },
|
||||
{ value: "weekly", label: "Weekly digest" },
|
||||
{ value: "mentions", label: "Only mentions" },
|
||||
]
|
||||
|
||||
export const RECOVERY_METHODS: SelectOption[] = [
|
||||
{ value: "authenticator", label: "Authenticator first" },
|
||||
{ value: "sms", label: "SMS fallback" },
|
||||
{ value: "email", label: "Email fallback" },
|
||||
]
|
||||
|
||||
export const BILLING_PLANS: BillingPlan[] = [
|
||||
{
|
||||
id: "free",
|
||||
name: "Free",
|
||||
price: "$0",
|
||||
period: "forever",
|
||||
current: false,
|
||||
features: ["1 workspace", "Basic exports", "Community support"],
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: "Pro",
|
||||
price: "$29",
|
||||
period: "per month",
|
||||
current: true,
|
||||
features: ["Unlimited workspaces", "Automations", "Priority support"],
|
||||
},
|
||||
{
|
||||
id: "team",
|
||||
name: "Team",
|
||||
price: "$79",
|
||||
period: "per month",
|
||||
current: false,
|
||||
features: ["Everything in Pro", "SSO", "Shared billing"],
|
||||
},
|
||||
]
|
||||
|
||||
export const SESSIONS: Session[] = [
|
||||
{
|
||||
id: "sess-1",
|
||||
device: "macOS",
|
||||
browser: "Chrome",
|
||||
location: "San Francisco, CA",
|
||||
lastActive: "Active now",
|
||||
current: true,
|
||||
icon: (
|
||||
<MonitorIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "sess-2",
|
||||
device: "iPhone",
|
||||
browser: "Safari",
|
||||
location: "San Francisco, CA",
|
||||
lastActive: "2 hours ago",
|
||||
current: false,
|
||||
icon: (
|
||||
<SmartphoneIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "sess-3",
|
||||
device: "Windows",
|
||||
browser: "Firefox",
|
||||
location: "New York, NY",
|
||||
lastActive: "3 days ago",
|
||||
current: false,
|
||||
icon: (
|
||||
<MonitorIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useState } from "react"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@evobgp/ui/components/field"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
import { Switch } from "@evobgp/ui/components/switch"
|
||||
|
||||
import { DIGEST_CADENCE, QUIET_HOURS, QUIET_HOURS_END, TIMEOUTS } from "./data"
|
||||
import { SettingRow } from "./setting-row"
|
||||
import { SettingsCard } from "./settings-card"
|
||||
import { SettingsFieldGroup } from "./settings-field-group"
|
||||
import { createSelectValueHandler, getOptionLabel } from "./utils"
|
||||
|
||||
export function NotificationsTab() {
|
||||
const [autoDismiss, setAutoDismiss] = useState("10")
|
||||
const [quietStart, setQuietStart] = useState("20:00")
|
||||
const [quietEnd, setQuietEnd] = useState("08:00")
|
||||
const [digestCadence, setDigestCadence] = useState("weekly")
|
||||
const handleAutoDismissChange = createSelectValueHandler(setAutoDismiss)
|
||||
const handleQuietStartChange = createSelectValueHandler(setQuietStart)
|
||||
const handleQuietEndChange = createSelectValueHandler(setQuietEnd)
|
||||
const handleDigestCadenceChange = createSelectValueHandler(setDigestCadence)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Card */}
|
||||
<SettingsCard title="Activity alerts" description="Desktop and badge">
|
||||
<SettingsFieldGroup
|
||||
legend="Activity alerts"
|
||||
description="Control desktop, badge, and sound alerts."
|
||||
>
|
||||
<SettingRow
|
||||
title="Desktop notifications"
|
||||
description="Show alerts for mentions and approvals."
|
||||
labelFor="settings-7-desktop-notifications"
|
||||
>
|
||||
<Switch id="settings-7-desktop-notifications" defaultChecked />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Quiet hours"
|
||||
description="Hold non-urgent alerts outside your workday."
|
||||
contentClassName="@md/field-group:w-[22rem]"
|
||||
>
|
||||
<FieldSet className="w-full gap-3">
|
||||
<FieldLegend className="sr-only">Quiet hours</FieldLegend>
|
||||
<FieldDescription className="sr-only">
|
||||
Define when non-urgent activity should stay muted.
|
||||
</FieldDescription>
|
||||
|
||||
<FieldGroup className="gap-3 sm:grid sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-7-quiet-start">
|
||||
Start
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={quietStart}
|
||||
onValueChange={handleQuietStartChange}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="settings-7-quiet-start"
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue>
|
||||
{getOptionLabel(QUIET_HOURS, quietStart)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{QUIET_HOURS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-7-quiet-end">End</FieldLabel>
|
||||
<Select value={quietEnd} onValueChange={handleQuietEndChange}>
|
||||
<SelectTrigger id="settings-7-quiet-end" className="w-full">
|
||||
<SelectValue>
|
||||
{getOptionLabel(QUIET_HOURS_END, quietEnd)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{QUIET_HOURS_END.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Unread badge"
|
||||
description="Display a badge when new activity arrives."
|
||||
labelFor="settings-7-unread-badge"
|
||||
>
|
||||
<Switch id="settings-7-unread-badge" defaultChecked />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Auto dismiss"
|
||||
description="Choose how long alerts stay visible."
|
||||
labelFor="settings-7-auto-dismiss"
|
||||
last
|
||||
>
|
||||
<Select value={autoDismiss} onValueChange={handleAutoDismissChange}>
|
||||
<SelectTrigger id="settings-7-auto-dismiss" className="w-full">
|
||||
<SelectValue>
|
||||
{getOptionLabel(TIMEOUTS, autoDismiss)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{TIMEOUTS.map((timeout) => (
|
||||
<SelectItem key={timeout.value} value={timeout.value}>
|
||||
{timeout.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingRow>
|
||||
</SettingsFieldGroup>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Email updates"
|
||||
description="Inbox preferences"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline">Reset</Button>
|
||||
<Button>Save preferences</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SettingsFieldGroup
|
||||
legend="Email updates"
|
||||
description="Control which emails reach your inbox."
|
||||
>
|
||||
<SettingRow
|
||||
title="Team communication"
|
||||
description="Messages, approvals, and activity summaries."
|
||||
labelFor="settings-7-team-communication"
|
||||
>
|
||||
<Switch id="settings-7-team-communication" defaultChecked />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Product announcements"
|
||||
description="Releases, improvements, and launches."
|
||||
labelFor="settings-7-product-announcements"
|
||||
>
|
||||
<Switch id="settings-7-product-announcements" />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Weekly digest"
|
||||
description="A recap of workspaces, mentions, and tasks."
|
||||
labelFor="settings-7-weekly-digest"
|
||||
>
|
||||
<Switch id="settings-7-weekly-digest" defaultChecked />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Digest cadence"
|
||||
description="Set how often summary emails arrive."
|
||||
labelFor="settings-7-digest-cadence"
|
||||
last
|
||||
>
|
||||
<Select
|
||||
value={digestCadence}
|
||||
onValueChange={handleDigestCadenceChange}
|
||||
>
|
||||
<SelectTrigger id="settings-7-digest-cadence" className="w-full">
|
||||
<SelectValue>
|
||||
{getOptionLabel(DIGEST_CADENCE, digestCadence)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{DIGEST_CADENCE.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingRow>
|
||||
</SettingsFieldGroup>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useFileUpload } from "@/hooks/use-file-upload"
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@evobgp/ui/components/avatar"
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import { UserCircle, XIcon, UploadIcon } from "lucide-react"
|
||||
|
||||
interface ProfileAvatarUploadProps {
|
||||
defaultAvatar: string
|
||||
alt: string
|
||||
inputId?: string
|
||||
}
|
||||
|
||||
export function ProfileAvatarUpload({
|
||||
defaultAvatar,
|
||||
alt,
|
||||
inputId,
|
||||
}: ProfileAvatarUploadProps) {
|
||||
const [removedCurrentPhoto, setRemovedCurrentPhoto] = useState(false)
|
||||
const [{ files }, { removeFile, openFileDialog, getInputProps }] =
|
||||
useFileUpload({
|
||||
accept: "image/*",
|
||||
})
|
||||
|
||||
const currentFile = files[0] ?? null
|
||||
const hasSavedPhoto = Boolean(defaultAvatar) && !removedCurrentPhoto
|
||||
const previewUrl =
|
||||
currentFile?.preview ?? (hasSavedPhoto ? defaultAvatar : null)
|
||||
const hasPhoto = Boolean(previewUrl)
|
||||
|
||||
const handleCancelUpload = () => {
|
||||
if (!currentFile) {
|
||||
return
|
||||
}
|
||||
|
||||
removeFile(currentFile.id)
|
||||
}
|
||||
|
||||
const handleRemovePhoto = () => {
|
||||
if (currentFile) {
|
||||
removeFile(currentFile.id)
|
||||
}
|
||||
|
||||
setRemovedCurrentPhoto(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-wrap items-center justify-start gap-2">
|
||||
{/* Actions */}
|
||||
<div className="relative">
|
||||
<Avatar className="size-10">
|
||||
<AvatarImage src={previewUrl ?? undefined} alt={alt} />
|
||||
<AvatarFallback className="text-muted-foreground bg-muted">
|
||||
<UserCircle aria-hidden="true" className="size-4 opacity-60" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
{currentFile ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
onClick={handleCancelUpload}
|
||||
className="absolute -top-1 -right-1 size-4 rounded-full"
|
||||
aria-label={`Cancel ${currentFile.file.name}`}
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="relative inline-flex">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openFileDialog}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<UploadIcon aria-hidden="true" />
|
||||
{hasPhoto ? "Change" : "Upload"}
|
||||
</Button>
|
||||
<input
|
||||
{...getInputProps({ id: inputId })}
|
||||
className="sr-only"
|
||||
aria-label="Upload profile image"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasPhoto ? (
|
||||
<Button variant="outline" size="sm" onClick={handleRemovePhoto}>
|
||||
<XIcon aria-hidden="true" />
|
||||
Remove
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Alert,
|
||||
AlertAction,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/reui/alert"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@evobgp/ui/components/field"
|
||||
import { Input } from "@evobgp/ui/components/input"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@evobgp/ui/components/input-group"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
import { Textarea } from "@evobgp/ui/components/textarea"
|
||||
import { ROLES, TIMEZONES } from "./data"
|
||||
import { ProfileAvatarUpload } from "./profile-avatar-upload"
|
||||
import { SettingRow } from "./setting-row"
|
||||
import { SettingsCard } from "./settings-card"
|
||||
import { SettingsFieldGroup } from "./settings-field-group"
|
||||
import { createSelectValueHandler, getOptionLabel } from "./utils"
|
||||
import { UserIcon } from "lucide-react"
|
||||
|
||||
export function ProfileTab() {
|
||||
const [role, setRole] = useState("engineering-lead")
|
||||
const [timezone, setTimezone] = useState("utc-8")
|
||||
const handleRoleChange = createSelectValueHandler(setRole)
|
||||
const handleTimezoneChange = createSelectValueHandler(setTimezone)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Alert variant="warning">
|
||||
<UserIcon aria-hidden="true" />
|
||||
<AlertTitle>Complete your profile.</AlertTitle>
|
||||
<AlertDescription>
|
||||
Add a photo and keep your role and timezone current.
|
||||
</AlertDescription>
|
||||
<AlertAction>
|
||||
<Button type="button" variant="outline" size="xs">
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button type="button" size="xs">
|
||||
Update
|
||||
</Button>
|
||||
</AlertAction>
|
||||
</Alert>
|
||||
|
||||
{/* Card */}
|
||||
<SettingsCard
|
||||
title="My profile"
|
||||
description="Public account details"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button>Save changes</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SettingsFieldGroup
|
||||
legend="Profile fields"
|
||||
description="Update your personal profile details."
|
||||
>
|
||||
<SettingRow
|
||||
title="Photo"
|
||||
description="Shown in comments and mentions."
|
||||
>
|
||||
<ProfileAvatarUpload
|
||||
defaultAvatar="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80"
|
||||
alt="Alex Morgan"
|
||||
inputId="settings-7-profile-photo"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Full name"
|
||||
description="Used across the workspace."
|
||||
labelFor="settings-7-full-name"
|
||||
>
|
||||
<Input id="settings-7-full-name" defaultValue="Alex Morgan" />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Email address"
|
||||
description="Primary sign-in email."
|
||||
labelFor="settings-7-email"
|
||||
titleAddon={<Badge variant="success-light">Verified</Badge>}
|
||||
>
|
||||
<Input
|
||||
id="settings-7-email"
|
||||
defaultValue="[email protected]"
|
||||
type="email"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Username"
|
||||
description="Visible in mentions and links."
|
||||
labelFor="settings-7-username"
|
||||
>
|
||||
<InputGroup className="w-full">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<InputGroupText>@</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="settings-7-username"
|
||||
defaultValue="alexmorgan"
|
||||
/>
|
||||
</InputGroup>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Profile details"
|
||||
description="Public details shared across the workspace."
|
||||
contentClassName="@md/field-group:w-[22rem]"
|
||||
>
|
||||
<FieldSet className="w-full gap-3">
|
||||
<FieldLegend className="sr-only">Profile details</FieldLegend>
|
||||
<FieldDescription className="sr-only">
|
||||
Public profile and workspace defaults.
|
||||
</FieldDescription>
|
||||
|
||||
<FieldGroup className="gap-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-7-role">Role</FieldLabel>
|
||||
<Select value={role} onValueChange={handleRoleChange}>
|
||||
<SelectTrigger id="settings-7-role" className="w-full">
|
||||
<SelectValue>{getOptionLabel(ROLES, role)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{ROLES.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-7-timezone">
|
||||
Time zone
|
||||
</FieldLabel>
|
||||
<Select value={timezone} onValueChange={handleTimezoneChange}>
|
||||
<SelectTrigger id="settings-7-timezone" className="w-full">
|
||||
<SelectValue>
|
||||
{getOptionLabel(TIMEZONES, timezone)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{TIMEZONES.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-7-website">Website</FieldLabel>
|
||||
<InputGroup className="w-full">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<InputGroupText>https://</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="settings-7-website"
|
||||
defaultValue="alexmorgan.dev"
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Bio"
|
||||
description="Short profile summary."
|
||||
labelFor="settings-7-bio"
|
||||
last
|
||||
>
|
||||
<Textarea
|
||||
id="settings-7-bio"
|
||||
defaultValue="Building developer tools at Acme. Previously at Vercel and Stripe."
|
||||
rows={4}
|
||||
className="min-h-24 resize-none"
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingsFieldGroup>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
"use client"
|
||||
|
||||
import { Fragment, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@evobgp/ui/components/field"
|
||||
import { Input } from "@evobgp/ui/components/input"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@evobgp/ui/components/input-group"
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemGroup,
|
||||
ItemMedia,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
} from "@evobgp/ui/components/item"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evobgp/ui/components/select"
|
||||
import { Switch } from "@evobgp/ui/components/switch"
|
||||
import { RECOVERY_METHODS, SESSIONS } from "./data"
|
||||
import { SettingRow } from "./setting-row"
|
||||
import { SettingsCard } from "./settings-card"
|
||||
import { SettingsFieldGroup } from "./settings-field-group"
|
||||
import { createSelectValueHandler, getOptionLabel } from "./utils"
|
||||
import { LogOutIcon } from "lucide-react"
|
||||
|
||||
export function SecurityTab() {
|
||||
const [recoveryMethod, setRecoveryMethod] = useState("authenticator")
|
||||
const handleRecoveryMethodChange = createSelectValueHandler(setRecoveryMethod)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Card */}
|
||||
<SettingsCard
|
||||
title="Change password"
|
||||
description="Access protection"
|
||||
footer={<Button>Update password</Button>}
|
||||
>
|
||||
<SettingsFieldGroup
|
||||
legend="Password settings"
|
||||
description="Update your password and keep your account protected."
|
||||
>
|
||||
<SettingRow
|
||||
title="Current password"
|
||||
description="Verify your identity before making changes."
|
||||
labelFor="settings-7-current-password"
|
||||
>
|
||||
<Input
|
||||
id="settings-7-current-password"
|
||||
type="password"
|
||||
placeholder="Enter current password"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="New password"
|
||||
description="Use at least 12 characters and a unique phrase."
|
||||
contentClassName="@md/field-group:w-[22rem]"
|
||||
last
|
||||
>
|
||||
<FieldSet className="w-full gap-3">
|
||||
<FieldLegend className="sr-only">New password</FieldLegend>
|
||||
<FieldDescription className="sr-only">
|
||||
Create and confirm the next password for this account.
|
||||
</FieldDescription>
|
||||
|
||||
<FieldGroup className="gap-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-7-new-password">
|
||||
New password
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="settings-7-new-password"
|
||||
type="password"
|
||||
placeholder="Create a new password"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-7-confirm-password">
|
||||
Confirm password
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="settings-7-confirm-password"
|
||||
type="password"
|
||||
placeholder="Confirm the new password"
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</SettingRow>
|
||||
</SettingsFieldGroup>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Two-step verification"
|
||||
description="Extra sign-in checks"
|
||||
>
|
||||
<SettingsFieldGroup
|
||||
legend="Two-step verification"
|
||||
description="Add backup checks for future sign-ins."
|
||||
>
|
||||
<SettingRow
|
||||
title="Authenticator app"
|
||||
description="Use one-time codes from an app."
|
||||
labelFor="settings-7-authenticator-app"
|
||||
titleAddon={
|
||||
<Badge variant="info-light" size="sm">
|
||||
Recommended
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<Switch id="settings-7-authenticator-app" defaultChecked />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Recovery phone"
|
||||
description="Used if you lose access to your authenticator app."
|
||||
labelFor="settings-7-recovery-phone"
|
||||
>
|
||||
<InputGroup className="w-full">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<InputGroupText>+1</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="settings-7-recovery-phone"
|
||||
defaultValue="415 555 0148"
|
||||
/>
|
||||
</InputGroup>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Delivery method"
|
||||
description="Choose how backup verification requests are delivered."
|
||||
labelFor="settings-7-recovery-method"
|
||||
>
|
||||
<Select
|
||||
value={recoveryMethod}
|
||||
onValueChange={handleRecoveryMethodChange}
|
||||
>
|
||||
<SelectTrigger id="settings-7-recovery-method" className="w-full">
|
||||
<SelectValue>
|
||||
{getOptionLabel(RECOVERY_METHODS, recoveryMethod)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{RECOVERY_METHODS.map((method) => (
|
||||
<SelectItem key={method.value} value={method.value}>
|
||||
{method.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Trusted devices"
|
||||
description="Skip repeat prompts on known devices."
|
||||
labelFor="settings-7-trusted-devices"
|
||||
last
|
||||
>
|
||||
<Switch id="settings-7-trusted-devices" defaultChecked />
|
||||
</SettingRow>
|
||||
</SettingsFieldGroup>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="Active sessions" description="Signed-in devices">
|
||||
<ItemGroup className="gap-0">
|
||||
{SESSIONS.map((session, index) => (
|
||||
<Fragment key={session.id}>
|
||||
{index > 0 ? <ItemSeparator className="my-0" /> : null}
|
||||
<Item className="min-h-0 items-center gap-4 px-5 py-3.5">
|
||||
<ItemMedia className="self-center!">
|
||||
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
|
||||
{session.icon}
|
||||
</Item>
|
||||
</ItemMedia>
|
||||
|
||||
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
||||
<ItemTitle className="gap-2 leading-5">
|
||||
{session.browser} on {session.device}
|
||||
{session.current ? (
|
||||
<Badge variant="success-light" size="xs">
|
||||
Current
|
||||
</Badge>
|
||||
) : null}
|
||||
</ItemTitle>
|
||||
<ItemDescription className="leading-5">
|
||||
{session.location} · {session.lastActive}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
|
||||
<ItemActions className="w-28 shrink-0 justify-end self-center">
|
||||
{session.current ? (
|
||||
<Button variant="outline" size="sm">
|
||||
This device
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm">
|
||||
<LogOutIcon data-icon="inline-start" aria-hidden="true" />
|
||||
Revoke
|
||||
</Button>
|
||||
)}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</Fragment>
|
||||
))}
|
||||
</ItemGroup>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="Delete account" description="Irreversible changes">
|
||||
<SettingsFieldGroup
|
||||
legend="Delete account"
|
||||
description="Review irreversible actions before deleting your account."
|
||||
>
|
||||
<SettingRow
|
||||
title="Close account"
|
||||
description="Permanently remove your account, sessions, and recovery settings."
|
||||
last
|
||||
contentClassName="@md/field-group:w-[22rem]"
|
||||
>
|
||||
<div className="flex w-full flex-col items-start gap-2 @md/field-group:items-end">
|
||||
<p className="text-muted-foreground text-xs leading-5">
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
<Button variant="destructive">Delete account</Button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</SettingsFieldGroup>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
FieldTitle,
|
||||
} from "@evobgp/ui/components/field"
|
||||
|
||||
interface SettingRowProps {
|
||||
title: string
|
||||
description?: ReactNode
|
||||
children: ReactNode
|
||||
last?: boolean
|
||||
labelFor?: string
|
||||
contentClassName?: string
|
||||
titleAddon?: ReactNode
|
||||
}
|
||||
|
||||
export function SettingRow({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
last,
|
||||
labelFor,
|
||||
contentClassName,
|
||||
titleAddon,
|
||||
}: SettingRowProps) {
|
||||
return (
|
||||
<>
|
||||
<Field orientation="responsive" className="gap-4 px-5 py-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{labelFor ? (
|
||||
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
||||
) : (
|
||||
<FieldTitle>{title}</FieldTitle>
|
||||
)}
|
||||
{titleAddon}
|
||||
</div>
|
||||
|
||||
{description ? (
|
||||
<FieldDescription>{description}</FieldDescription>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<FieldContent
|
||||
className={cn("min-w-0 @md/field-group:w-78", contentClassName)}
|
||||
>
|
||||
<div className="flex w-full justify-start @md/field-group:justify-end">
|
||||
{children}
|
||||
</div>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
{!last ? <FieldSeparator /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client"
|
||||
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@evobgp/ui/components/card"
|
||||
|
||||
interface SettingsCardProps {
|
||||
title: string
|
||||
description?: string
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
footerClassName?: string
|
||||
}
|
||||
|
||||
export function SettingsCard({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
className,
|
||||
contentClassName,
|
||||
footerClassName,
|
||||
}: SettingsCardProps) {
|
||||
return (
|
||||
<Card className={cn("gap-0 p-0", className)}>
|
||||
{/* Header */}
|
||||
<CardHeader className="gap-0 border-b px-5 py-3">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
||||
</CardHeader>
|
||||
|
||||
{/* Content */}
|
||||
<CardContent className={cn("p-0", contentClassName)}>
|
||||
{children}
|
||||
</CardContent>
|
||||
|
||||
{footer ? (
|
||||
<CardFooter
|
||||
className={cn(
|
||||
"justify-end gap-2 border-t px-5 py-3",
|
||||
footerClassName
|
||||
)}
|
||||
>
|
||||
{footer}
|
||||
</CardFooter>
|
||||
) : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
import {
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@evobgp/ui/components/field"
|
||||
|
||||
interface SettingsFieldGroupProps {
|
||||
legend: string
|
||||
description: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
fieldGroupClassName?: string
|
||||
}
|
||||
|
||||
export function SettingsFieldGroup({
|
||||
legend,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
fieldGroupClassName,
|
||||
}: SettingsFieldGroupProps) {
|
||||
return (
|
||||
<FieldSet className={cn("gap-0", className)}>
|
||||
<FieldLegend className="sr-only">{legend}</FieldLegend>
|
||||
{/* Description */}
|
||||
<FieldDescription className="sr-only">{description}</FieldDescription>
|
||||
{/* List */}
|
||||
<FieldGroup className={cn("gap-0", fieldGroupClassName)}>
|
||||
{children}
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client"
|
||||
|
||||
import { type Dispatch, type SetStateAction } from "react"
|
||||
|
||||
import type { SelectOption } from "./data"
|
||||
|
||||
export function getOptionLabel<T extends SelectOption>(
|
||||
options: T[],
|
||||
value: string
|
||||
) {
|
||||
return options.find((option) => option.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
export function createSelectValueHandler(
|
||||
setValue: Dispatch<SetStateAction<string>>
|
||||
) {
|
||||
return (value: string | null) => {
|
||||
if (value !== null) {
|
||||
setValue(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { AccountSettings } from "./components/account-settings"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-start justify-center p-4 sm:p-6 md:p-10">
|
||||
<AccountSettings />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,22 +8,13 @@ import {
|
||||
} from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { dashboardKpiGridClassName, kpiCardContentClassName } from '@/lib/ui-surface'
|
||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||
|
||||
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
|
||||
import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
|
||||
|
||||
type KpiCard = {
|
||||
icon: ReactNode
|
||||
iconClass: string
|
||||
value: string
|
||||
label: string
|
||||
badge: ReactNode
|
||||
}
|
||||
type KpiCard = KpiStatItem & { icon: ReactNode }
|
||||
|
||||
function buildKpis({
|
||||
modules,
|
||||
@@ -52,22 +43,24 @@ function buildKpis({
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'modules',
|
||||
icon: <Boxes aria-hidden />,
|
||||
iconClass: 'text-primary',
|
||||
iconClassName: 'text-primary',
|
||||
value: loading ? '—' : `${enabledModules}/${modules.length || 0}`,
|
||||
label: 'Модули активны',
|
||||
badge: (
|
||||
footer: (
|
||||
<Badge variant="primary-light" size="sm">
|
||||
{loading ? '…' : `${modules.length} всего`}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'bgp',
|
||||
icon: <Network aria-hidden />,
|
||||
iconClass: 'text-info',
|
||||
iconClassName: 'text-info',
|
||||
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
|
||||
label: 'BGP готовность',
|
||||
badge: (
|
||||
footer: (
|
||||
<Badge
|
||||
variant={
|
||||
bgpPct !== null && bgpPct >= 90
|
||||
@@ -85,22 +78,24 @@ function buildKpis({
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'peers',
|
||||
icon: <Share2 aria-hidden />,
|
||||
iconClass: 'text-success',
|
||||
iconClassName: 'text-success',
|
||||
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
|
||||
label: 'Пиры Established',
|
||||
badge: (
|
||||
footer: (
|
||||
<Badge variant="success-light" size="sm">
|
||||
{loading ? '…' : `${network.peersTotal} в каталоге`}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'speakers',
|
||||
icon: <ServerCog aria-hidden />,
|
||||
iconClass: 'text-warning',
|
||||
iconClassName: 'text-warning',
|
||||
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
||||
label: 'Спикеры online',
|
||||
badge: (
|
||||
footer: (
|
||||
<Badge
|
||||
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
|
||||
size="sm"
|
||||
@@ -110,22 +105,24 @@ function buildKpis({
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'jobs',
|
||||
icon: <ListChecks aria-hidden />,
|
||||
iconClass: 'text-focus',
|
||||
iconClassName: 'text-focus',
|
||||
value: loading ? '—' : String(running),
|
||||
label: 'Активные задачи',
|
||||
badge: (
|
||||
footer: (
|
||||
<Badge variant={running > 0 ? 'info-light' : 'outline'} size="sm">
|
||||
{loading ? '…' : `${jobs.length} в выборке`}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'risks',
|
||||
icon: <AlertTriangle aria-hidden />,
|
||||
iconClass: 'text-destructive',
|
||||
iconClassName: 'text-destructive',
|
||||
value: loading ? '—' : String(riskCount),
|
||||
label: 'Риски',
|
||||
badge: (
|
||||
footer: (
|
||||
<Badge variant={riskCount > 0 ? 'destructive-light' : 'success-light'} size="sm">
|
||||
{loading
|
||||
? '…'
|
||||
@@ -151,33 +148,10 @@ export function DashboardKpiGrid({
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const cards = buildKpis({ modules, peers, speakers, jobs, loading })
|
||||
|
||||
return (
|
||||
<section aria-label="KPI обзора" className={dashboardKpiGridClassName}>
|
||||
{cards.map((card) => (
|
||||
<Card key={card.label} size="sm" className="gap-0">
|
||||
<CardContent className={cn(kpiCardContentClassName, 'gap-3 p-4')}>
|
||||
<Item
|
||||
className={cn(
|
||||
'border-background bg-muted flex size-9 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||
card.iconClass,
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{card.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-foreground text-xl leading-none font-bold tabular-nums">
|
||||
{card.value}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs font-medium">{card.label}</div>
|
||||
</div>
|
||||
{card.badge}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</section>
|
||||
<KpiStatGrid
|
||||
items={buildKpis({ modules, peers, speakers, jobs, loading })}
|
||||
aria-label="KPI обзора"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Button } from "@evobgp/ui/components/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@evobgp/ui/components/card"
|
||||
import { Input } from "@evobgp/ui/components/input"
|
||||
import { Label } from "@evobgp/ui/components/label"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@evobgp/ui/components/tabs"
|
||||
|
||||
export function Pattern() {
|
||||
return (
|
||||
<div className="flex w-full max-w-xs flex-col gap-6">
|
||||
<Tabs defaultValue="account">
|
||||
<TabsList variant="line" className="mb-3.5 w-full">
|
||||
<TabsTrigger value="account">Account</TabsTrigger>
|
||||
<TabsTrigger value="password">Password</TabsTrigger>
|
||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="account">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Account</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
Update your account information.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="underline-name" className="text-sm">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="underline-name"
|
||||
defaultValue="Alex Chen"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="underline-email" className="text-sm">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="underline-email"
|
||||
type="email"
|
||||
defaultValue="[email protected]"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="pt-3">
|
||||
<Button size="sm">Save changes</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="password">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Password</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
Change your password here.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="underline-current" className="text-sm">
|
||||
Current password
|
||||
</Label>
|
||||
<Input id="underline-current" type="password" className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="underline-new" className="text-sm">
|
||||
New password
|
||||
</Label>
|
||||
<Input id="underline-new" type="password" className="h-9" />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="pt-3">
|
||||
<Button size="sm">Update password</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="settings">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Settings</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
Manage your preferences.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="underline-theme" className="text-sm">
|
||||
Theme
|
||||
</Label>
|
||||
<Input
|
||||
id="underline-theme"
|
||||
defaultValue="Light"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="underline-language" className="text-sm">
|
||||
Language
|
||||
</Label>
|
||||
<Input
|
||||
id="underline-language"
|
||||
defaultValue="English"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="pt-3">
|
||||
<Button size="sm">Save settings</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { KeyboardEvent, ReactNode } from 'react'
|
||||
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||
|
||||
export type KpiStatItem = {
|
||||
id?: string
|
||||
icon?: ReactNode
|
||||
iconClassName?: string
|
||||
value: ReactNode
|
||||
label: ReactNode
|
||||
footer?: ReactNode
|
||||
active?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_ICON_CLASS =
|
||||
'text-muted-foreground [&_svg]:text-current'
|
||||
|
||||
function kpiStatGridClassName(count: number): string {
|
||||
if (count <= 1) return 'grid-cols-1'
|
||||
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
|
||||
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
||||
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
|
||||
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
|
||||
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
|
||||
}
|
||||
|
||||
function handleCardKeyDown(onClick: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
|
||||
/** Single KPI tile (ReUI stats-12 / dashboard PRO pattern). */
|
||||
export function KpiStatCard({
|
||||
item,
|
||||
embedded = false,
|
||||
className,
|
||||
}: {
|
||||
item: KpiStatItem
|
||||
embedded?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const clickable = Boolean(item.onClick)
|
||||
|
||||
const panel = (
|
||||
<FramePanel
|
||||
className={cn(
|
||||
'flex h-full flex-col items-start gap-6',
|
||||
clickable && 'cursor-pointer transition-colors hover:bg-muted/30',
|
||||
item.active && 'ring-1 ring-primary/30',
|
||||
className,
|
||||
)}
|
||||
onClick={item.onClick}
|
||||
role={clickable ? 'button' : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
onKeyDown={clickable && item.onClick ? (e) => handleCardKeyDown(item.onClick!, e) : undefined}
|
||||
>
|
||||
{item.icon ? (
|
||||
<Item
|
||||
className={cn(
|
||||
'border-background bg-muted flex size-10.5 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||
item.iconClassName ?? DEFAULT_ICON_CLASS,
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{item.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-foreground text-2xl leading-none font-bold tabular-nums">{item.value}</div>
|
||||
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
|
||||
</div>
|
||||
|
||||
{item.footer ? <div className="mt-auto w-full">{item.footer}</div> : null}
|
||||
</FramePanel>
|
||||
)
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<Frame className="h-full ring-1 ring-foreground/10">
|
||||
{panel}
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
return <Frame className="h-full">{panel}</Frame>
|
||||
}
|
||||
|
||||
export function KpiStatGrid({
|
||||
items,
|
||||
className,
|
||||
embedded = false,
|
||||
'aria-label': ariaLabel,
|
||||
}: {
|
||||
items: KpiStatItem[]
|
||||
className?: string
|
||||
embedded?: boolean
|
||||
'aria-label'?: string
|
||||
}) {
|
||||
return (
|
||||
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
|
||||
<div className={cn('grid gap-5', kpiStatGridClassName(items.length))}>
|
||||
{items.map((item, index) => (
|
||||
<KpiStatCard
|
||||
key={item.id ?? (typeof item.label === 'string' ? item.label : `kpi-${index}`)}
|
||||
item={item}
|
||||
embedded={embedded}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function kpiStatItemKey(item: KpiStatItem, index: number): string {
|
||||
if (item.id) return item.id
|
||||
if (typeof item.label === 'string') return item.label
|
||||
return `kpi-${index}`
|
||||
}
|
||||
@@ -92,7 +92,7 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
items: [
|
||||
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
|
||||
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog, description: 'Tenant BIRD config' },
|
||||
{ to: '/settings', label: 'Настройки UI', icon: Settings, description: 'Токен и подключение' },
|
||||
{ to: '/settings', label: 'Настройки UI', icon: Settings, description: 'Токен и подключение', search: { tab: 'connection' } },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { KpiSparklineCard, type KpiSparklineMetric } from '@/components/patterns/kpi-sparkline-card'
|
||||
import { kpiGridClassName } from '@/lib/ui-surface'
|
||||
import { Clock, Gauge, Globe, Tags } from 'lucide-react'
|
||||
|
||||
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
|
||||
import {
|
||||
communityLabel,
|
||||
@@ -7,7 +9,7 @@ import {
|
||||
moduleDohProfileIds,
|
||||
} from '@/lib/modules/helpers'
|
||||
import { dohPolicyRu } from '@/lib/ui-labels'
|
||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
import { KpiStatGridSkeleton } from '@/components/skeletons'
|
||||
import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '@/types/api'
|
||||
|
||||
interface ModuleKpiCardsProps {
|
||||
@@ -18,12 +20,6 @@ interface ModuleKpiCardsProps {
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
function syntheticSparkline(seed: number): number[] {
|
||||
return Array.from({ length: 9 }, (_, i) =>
|
||||
Math.round(seed * (0.8 + (i / 9) * 0.2 + Math.sin(i) * 0.05)),
|
||||
)
|
||||
}
|
||||
|
||||
export function ModuleKpiCards({
|
||||
mod,
|
||||
communities,
|
||||
@@ -32,67 +28,65 @@ export function ModuleKpiCards({
|
||||
loading = false,
|
||||
}: ModuleKpiCardsProps) {
|
||||
if (loading || !mod) {
|
||||
return <SectionCardsSkeleton count={4} />
|
||||
return <KpiStatGridSkeleton count={4} />
|
||||
}
|
||||
|
||||
const asPrefixTotal = asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
|
||||
const dohIds = moduleDohProfileIds(mod)
|
||||
|
||||
const metrics: KpiSparklineMetric[] = [
|
||||
const items: KpiStatItem[] = [
|
||||
{
|
||||
id: 'priority',
|
||||
title: 'Приоритет',
|
||||
label: 'Порядок в ревизии',
|
||||
icon: <Gauge aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
value: String(mod.priority ?? 0),
|
||||
delta: mod.enabled !== false ? 'активен' : 'выключен',
|
||||
deltaVariant: mod.enabled !== false ? 'success-light' : 'outline',
|
||||
detail: mod.type,
|
||||
tone: 'info',
|
||||
sparkline: syntheticSparkline(mod.priority ?? 1),
|
||||
label: 'Приоритет',
|
||||
footer: (
|
||||
<Badge variant={mod.enabled !== false ? 'success-light' : 'outline'} size="sm">
|
||||
{mod.enabled !== false ? 'активен' : 'выключен'} · {mod.type}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'interval',
|
||||
title: 'Интервал',
|
||||
label: 'Обновление',
|
||||
icon: <Clock aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
value: moduleIntervalLabel(mod),
|
||||
delta: formatDateTime(mod.last_refreshed_at) || 'никогда',
|
||||
deltaVariant: 'primary-light',
|
||||
detail: 'last refresh',
|
||||
tone: 'warning',
|
||||
sparkline: syntheticSparkline(12),
|
||||
label: 'Интервал обновления',
|
||||
footer: (
|
||||
<Badge variant="primary-light" size="sm">
|
||||
{formatDateTime(mod.last_refreshed_at) || 'никогда'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'prefixes',
|
||||
title: 'Префиксы',
|
||||
label: mod.type === 'AS_PREFIXES' ? 'AS entries' : 'Записи',
|
||||
icon: <Tags aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
value: mod.type === 'AS_PREFIXES' ? String(asPrefixTotal) : String(asEntries.length),
|
||||
delta: `${asEntries.length} AS`,
|
||||
deltaVariant: 'success-light',
|
||||
detail: 'в модуле',
|
||||
tone: 'success',
|
||||
sparkline: syntheticSparkline(asPrefixTotal || asEntries.length || 1),
|
||||
label: mod.type === 'AS_PREFIXES' ? 'Префиксы AS' : 'Записи модуля',
|
||||
footer: (
|
||||
<Badge variant="success-light" size="sm">
|
||||
{asEntries.length} AS · в модуле
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'policy',
|
||||
title: 'DoH / BGP',
|
||||
label: communityLabel(mod.default_community_id, communities),
|
||||
icon: <Globe aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
value: dohIds.length > 0 ? String(dohIds.length) : '—',
|
||||
delta: dohPolicyRu(mod.doh_resolver_policy),
|
||||
deltaVariant: 'info-light',
|
||||
detail:
|
||||
dohIds.length > 0
|
||||
? dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join(', ')
|
||||
: 'без DoH',
|
||||
tone: 'info',
|
||||
sparkline: syntheticSparkline(dohIds.length || 2),
|
||||
label: communityLabel(mod.default_community_id, communities),
|
||||
footer: (
|
||||
<Badge variant="info-light" size="sm">
|
||||
{dohPolicyRu(mod.doh_resolver_policy)}
|
||||
{dohIds.length > 0
|
||||
? ` · ${dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join(', ')}`
|
||||
: ' · без DoH'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section aria-label="KPI модуля" className={kpiGridClassName}>
|
||||
{metrics.map((metric) => (
|
||||
<KpiSparklineCard key={metric.id} metric={metric} />
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
return <KpiStatGrid items={items} aria-label="KPI модуля" />
|
||||
}
|
||||
|
||||
@@ -65,9 +65,9 @@ export function NetworkPeersCard({
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="border-b px-5 py-3">
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as PeerTab)}>
|
||||
<TabsList>
|
||||
<div className="px-5 pt-3">
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as PeerTab)} className="w-full">
|
||||
<TabsList variant="line" className="w-full justify-start gap-6">
|
||||
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger>
|
||||
<TabsTrigger value="established">Established ({counts.established})</TabsTrigger>
|
||||
<TabsTrigger value="pending">Ожидание ({counts.pending})</TabsTrigger>
|
||||
|
||||
@@ -52,9 +52,9 @@ export function OperationsJobsCard({
|
||||
|
||||
return (
|
||||
<DataGridCard title="Задачи" description="Фильтр по статусу · data-grid-filtering pattern">
|
||||
<div className="border-b px-5 py-3">
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as JobTab)}>
|
||||
<TabsList>
|
||||
<div className="px-5 pt-3">
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as JobTab)} className="w-full">
|
||||
<TabsList variant="line" className="w-full justify-start gap-6">
|
||||
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger>
|
||||
<TabsTrigger value="active">Активные ({counts.active})</TabsTrigger>
|
||||
<TabsTrigger value="succeeded">Успешные ({counts.succeeded})</TabsTrigger>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export { DonutBreakdownCard } from './donut-breakdown-card'
|
||||
export { IllustratedEmptyState } from './illustrated-empty-state'
|
||||
export { KpiSparklineCard, type KpiSparklineMetric } from './kpi-sparkline-card'
|
||||
export { KpiStatGrid, KpiStatCard, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||
export { PanelCorners } from './panel-corners'
|
||||
export { ProjectsEmptyState } from './projects-empty-state'
|
||||
export { SegmentedProgressCard, type SegmentStat } from './segmented-progress-card'
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
[
|
||||
"relative w-full text-sm border has-[>svg]:grid-cols-[calc(var(--spacing)*3)_1fr] grid-cols-[0_1fr] grid gap-y-0.5 items-center [&>svg:not([class*=size-])]:size-4",
|
||||
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_[data-slot=alert-action]]:sm:row-end-3",
|
||||
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:items-start",
|
||||
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_svg]:translate-y-0.5",
|
||||
"rounded-lg",
|
||||
"px-3",
|
||||
"py-2.5",
|
||||
"has-[>svg]:gap-x-2.5",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"border-destructive/30 bg-destructive/4 [&>svg]:text-destructive",
|
||||
info: "border-info/30 bg-info/4 [&>svg]:text-info",
|
||||
success: "border-success/30 bg-success/4 [&>svg]:text-success",
|
||||
warning: "border-warning/30 bg-warning/4 [&>svg]:text-warning",
|
||||
invert:
|
||||
"border-invert bg-invert text-invert-foreground [&_[data-slot=alert-description]]:text-invert-foreground/70",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-action"
|
||||
className={cn(
|
||||
"flex gap-1.5 max-sm:col-start-2 max-sm:mt-2 max-sm:justify-start sm:col-start-3 sm:row-start-1 sm:justify-end sm:self-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||
@@ -46,9 +46,9 @@ export function ScheduleJobsCard({
|
||||
|
||||
return (
|
||||
<DataGridCard title="Задачи" description="Последние задачи из API">
|
||||
<div className="border-b px-5 py-3">
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as JobTab)}>
|
||||
<TabsList>
|
||||
<div className="px-5 pt-3">
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as JobTab)} className="w-full">
|
||||
<TabsList variant="line" className="w-full justify-start gap-6">
|
||||
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger>
|
||||
<TabsTrigger value="refresh">Обновление ({counts.refresh})</TabsTrigger>
|
||||
<TabsTrigger value="failed">С ошибкой ({counts.failed})</TabsTrigger>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { TruncatedText } from '@/components/truncated-text'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||
|
||||
export interface SectionCardItem {
|
||||
label: ReactNode
|
||||
@@ -14,97 +14,46 @@ export interface SectionCardItem {
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
|
||||
default: '',
|
||||
warning: 'border-warning/50',
|
||||
destructive: 'border-destructive/50',
|
||||
}
|
||||
|
||||
function sectionGridClass(count: number): string {
|
||||
if (count <= 1) return 'grid-cols-1'
|
||||
if (count === 2) return 'sm:grid-cols-2'
|
||||
if (count === 3) return 'sm:grid-cols-2 lg:grid-cols-3'
|
||||
if (count === 4) return 'sm:grid-cols-2 lg:grid-cols-4'
|
||||
if (count === 5) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5'
|
||||
if (count === 6) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6'
|
||||
return 'sm:grid-cols-2 lg:grid-cols-3'
|
||||
}
|
||||
|
||||
const VALUE_VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
|
||||
default: '',
|
||||
warning: 'text-warning-foreground',
|
||||
const VARIANT_ICON_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
|
||||
default: 'text-muted-foreground',
|
||||
warning: 'text-warning',
|
||||
destructive: 'text-destructive',
|
||||
}
|
||||
|
||||
function hintToFooter(hint: ReactNode) {
|
||||
if (typeof hint === 'string') {
|
||||
return (
|
||||
<Badge variant="outline" size="sm">
|
||||
{hint}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return hint
|
||||
}
|
||||
|
||||
function toKpiStatItem(item: SectionCardItem, index: number): KpiStatItem {
|
||||
const footer =
|
||||
item.badge ?? (item.hint ? hintToFooter(item.hint) : undefined)
|
||||
|
||||
return {
|
||||
id: typeof item.label === 'string' ? item.label : `section-${index}`,
|
||||
icon: item.icon,
|
||||
iconClassName: VARIANT_ICON_CLASS[item.variant ?? 'default'],
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
footer,
|
||||
active: item.active,
|
||||
onClick: item.onClick,
|
||||
}
|
||||
}
|
||||
|
||||
/** KPI row for route sections — ReUI stats-12 / dashboard PRO pattern. */
|
||||
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
|
||||
return (
|
||||
<div className={cn('grid gap-3', sectionGridClass(items.length), className)}>
|
||||
{items.map((item, idx) => {
|
||||
const clickable = Boolean(item.onClick)
|
||||
const content = (
|
||||
<CardContent className="flex items-start gap-3 px-4 py-3">
|
||||
{item.icon ? (
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground">
|
||||
{item.icon}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{typeof item.label === 'string' ? (
|
||||
<TruncatedText className="text-xs text-muted-foreground">{item.label}</TruncatedText>
|
||||
) : (
|
||||
<span className="truncate text-xs text-muted-foreground">{item.label}</span>
|
||||
)}
|
||||
{item.badge ? <span className="shrink-0">{item.badge}</span> : null}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-baseline gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-lg font-semibold tabular-nums',
|
||||
VALUE_VARIANT_CLASS[item.variant ?? 'default'],
|
||||
)}
|
||||
>
|
||||
{item.value}
|
||||
</span>
|
||||
{item.hint ? (
|
||||
typeof item.hint === 'string' ? (
|
||||
<TruncatedText className="text-xs text-muted-foreground">· {item.hint}</TruncatedText>
|
||||
) : (
|
||||
<span className="truncate text-xs text-muted-foreground">· {item.hint}</span>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
)
|
||||
return (
|
||||
<Card
|
||||
key={typeof item.label === 'string' ? item.label : idx}
|
||||
size="sm"
|
||||
className={cn(
|
||||
'gap-0',
|
||||
VARIANT_CLASS[item.variant ?? 'default'],
|
||||
item.active && 'border-primary ring-1 ring-primary/30',
|
||||
clickable && 'cursor-pointer transition-colors hover:bg-muted/40',
|
||||
)}
|
||||
onClick={item.onClick}
|
||||
role={clickable ? 'button' : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
onKeyDown={
|
||||
clickable
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
item.onClick?.()
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<KpiStatGrid
|
||||
items={items.map(toKpiStatItem)}
|
||||
className={className}
|
||||
aria-label="Показатели"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Moon, Sun, SunMoon } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
|
||||
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
||||
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from '@evobgp/ui/components/toggle-group'
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ value: 'light', label: 'Светлая', icon: Sun },
|
||||
{ value: 'dark', label: 'Тёмная', icon: Moon },
|
||||
{ value: 'system', label: 'Система', icon: SunMoon },
|
||||
] as const
|
||||
|
||||
export function AppearanceSettingsTab() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingsCard
|
||||
title="Тема интерфейса"
|
||||
description="Быстрый переключатель также доступен в боковой панели"
|
||||
>
|
||||
<SettingsFieldGroup
|
||||
legend="Тема"
|
||||
description="Цветовая схема всех экранов в этом браузере."
|
||||
>
|
||||
<SettingRow
|
||||
title="Тема"
|
||||
description="Влияет на цветовую схему всех экранов в этом браузере."
|
||||
titleAddon={
|
||||
<Badge variant="primary-light" size="sm">
|
||||
мгновенно
|
||||
</Badge>
|
||||
}
|
||||
last
|
||||
>
|
||||
<ToggleGroup
|
||||
multiple={false}
|
||||
value={[theme ?? 'system']}
|
||||
onValueChange={(value) => {
|
||||
if (value.length > 0) setTheme(value[0])
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Тема интерфейса"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{THEME_OPTIONS.map((option) => {
|
||||
const Icon = option.icon
|
||||
return (
|
||||
<ToggleGroupItem key={option.value} value={option.value} className="gap-1.5">
|
||||
<Icon aria-hidden className="size-3.5" />
|
||||
{option.label}
|
||||
</ToggleGroupItem>
|
||||
)
|
||||
})}
|
||||
</ToggleGroup>
|
||||
</SettingRow>
|
||||
</SettingsFieldGroup>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Save } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
|
||||
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
||||
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import {
|
||||
DEV_API_TOKEN,
|
||||
normalizeApiToken,
|
||||
setToken,
|
||||
TOKEN_STORAGE_KEY,
|
||||
} from '@/lib/api-client'
|
||||
import { authKeys } from '@/queries/auth'
|
||||
import { Alert, AlertDescription } from '@evobgp/ui/components/alert'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
|
||||
export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolean }) {
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const [token, setTokenValue] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem(TOKEN_STORAGE_KEY) ?? ''
|
||||
setTokenValue(stored)
|
||||
}, [])
|
||||
|
||||
async function applyToken(raw: string) {
|
||||
const normalized = normalizeApiToken(raw)
|
||||
setToken(normalized || null)
|
||||
setTokenValue(normalized)
|
||||
await qc.invalidateQueries({ queryKey: authKeys.all })
|
||||
toast.success('Токен сохранён')
|
||||
if (normalized && tokenRequired) {
|
||||
void navigate({ to: '/dashboard' })
|
||||
}
|
||||
}
|
||||
|
||||
function saveToken() {
|
||||
void applyToken(token)
|
||||
}
|
||||
|
||||
function useDevToken() {
|
||||
void applyToken(DEV_API_TOKEN)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{tokenRequired ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Для доступа к разделу нужен API-токен. Сохраните токен ниже или нажмите «Использовать
|
||||
dev».
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<SettingsCard
|
||||
title="Подключение к API"
|
||||
description="Токен хранится только в этом браузере (localStorage)"
|
||||
footer={
|
||||
<div className="flex w-full flex-wrap justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={useDevToken}>
|
||||
Использовать dev
|
||||
</Button>
|
||||
<LoadingButton onClick={saveToken}>
|
||||
<Save />
|
||||
Сохранить токен
|
||||
</LoadingButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SettingsFieldGroup
|
||||
legend="API-токен"
|
||||
description="Параметры подключения браузера к EvoBGP API."
|
||||
>
|
||||
<SettingRow
|
||||
title="Токен для запросов"
|
||||
description={
|
||||
<>
|
||||
Ключ для заголовка <code className="text-xs">Authorization</code>. Управление
|
||||
ключами tenant — в разделе{' '}
|
||||
<Link to="/access" className="text-primary underline-offset-4 hover:underline">
|
||||
Права доступа
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
}
|
||||
titleAddon={
|
||||
<Badge variant="outline" size="sm">
|
||||
localStorage
|
||||
</Badge>
|
||||
}
|
||||
labelFor="settings-token"
|
||||
last
|
||||
>
|
||||
<Input
|
||||
id="settings-token"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={token}
|
||||
onChange={(e) => setTokenValue(e.target.value)}
|
||||
placeholder="dev или API-ключ"
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingsFieldGroup>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
ActivityIcon,
|
||||
KeyRoundIcon,
|
||||
SlidersHorizontalIcon,
|
||||
} from 'lucide-react'
|
||||
import { Fragment } from 'react'
|
||||
|
||||
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemGroup,
|
||||
ItemMedia,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
} from '@evobgp/ui/components/item'
|
||||
|
||||
const ADMIN_SECTIONS = [
|
||||
{
|
||||
id: 'tenant-settings',
|
||||
to: '/tenant-settings' as const,
|
||||
title: 'Параметры арендатора',
|
||||
description: 'BIRD, ревизии, файловые логи и дополнительные ключи /v1/settings.',
|
||||
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
||||
badge: { label: 'operator', variant: 'warning-light' as const },
|
||||
},
|
||||
{
|
||||
id: 'access',
|
||||
to: '/access' as const,
|
||||
title: 'Права доступа',
|
||||
description: 'API-ключи tenant, роли и управление доступом.',
|
||||
icon: <KeyRoundIcon aria-hidden="true" />,
|
||||
badge: { label: 'operator', variant: 'warning-light' as const },
|
||||
},
|
||||
{
|
||||
id: 'monitoring',
|
||||
to: '/monitoring' as const,
|
||||
title: 'Мониторинг',
|
||||
description: 'Метрики, состояние jobs и observability control plane.',
|
||||
icon: <ActivityIcon aria-hidden="true" />,
|
||||
badge: { label: 'viewer+', variant: 'info-light' as const },
|
||||
},
|
||||
] as const
|
||||
|
||||
export function SectionsSettingsTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingsCard
|
||||
title="Разделы control plane"
|
||||
description="Параметры tenant и операции — отдельно от настроек браузера"
|
||||
>
|
||||
<ItemGroup className="gap-0">
|
||||
{ADMIN_SECTIONS.map((section, index) => (
|
||||
<Fragment key={section.id}>
|
||||
{index > 0 ? <ItemSeparator className="my-0" /> : null}
|
||||
<Item className="min-h-0 items-center gap-4 px-5 py-3.5">
|
||||
<ItemMedia className="self-center!">
|
||||
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
|
||||
{section.icon}
|
||||
</Item>
|
||||
</ItemMedia>
|
||||
|
||||
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
||||
<ItemTitle className="gap-2 leading-5">
|
||||
{section.title}
|
||||
<Badge variant={section.badge.variant} size="xs">
|
||||
{section.badge.label}
|
||||
</Badge>
|
||||
</ItemTitle>
|
||||
<ItemDescription className="leading-5">{section.description}</ItemDescription>
|
||||
</ItemContent>
|
||||
|
||||
<ItemActions className="shrink-0 justify-end self-center">
|
||||
<Button variant="outline" size="sm" render={<Link to={section.to} />}>
|
||||
Открыть
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</Fragment>
|
||||
))}
|
||||
</ItemGroup>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { MonitorIcon } from 'lucide-react'
|
||||
|
||||
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
|
||||
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
||||
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||
import { authSessionQueryOptions } from '@/queries/auth'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemGroup,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from '@evobgp/ui/components/item'
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
viewer: 'Просмотр',
|
||||
editor: 'Редактор',
|
||||
operator: 'Оператор',
|
||||
node: 'Нода',
|
||||
}
|
||||
|
||||
export function SessionSettingsTab() {
|
||||
const hasStoredToken = Boolean(
|
||||
typeof window !== 'undefined' && window.localStorage.getItem(TOKEN_STORAGE_KEY)?.trim(),
|
||||
)
|
||||
|
||||
const sessionQ = useQuery({
|
||||
...authSessionQueryOptions(),
|
||||
enabled: hasStoredToken,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingsCard title="Текущая сессия" description="Проверка токена через GET /v1/auth/session">
|
||||
<QueryState
|
||||
data={sessionQ.data}
|
||||
isLoading={sessionQ.isLoading && hasStoredToken}
|
||||
isError={sessionQ.isError}
|
||||
error={sessionQ.error}
|
||||
empty={!hasStoredToken}
|
||||
emptyTitle="Токен не задан"
|
||||
emptyDescription="Сохраните API-токен во вкладке «Подключение»."
|
||||
skeleton={<div className="h-24 px-5 py-4" />}
|
||||
onRetry={() => sessionQ.refetch()}
|
||||
>
|
||||
{(session) => (
|
||||
<ItemGroup className="gap-0">
|
||||
<Item className="min-h-0 items-center gap-4 px-5 py-3.5">
|
||||
<ItemMedia className="self-center!">
|
||||
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
|
||||
<MonitorIcon aria-hidden="true" />
|
||||
</Item>
|
||||
</ItemMedia>
|
||||
|
||||
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
||||
<ItemTitle className="gap-2 leading-5">
|
||||
Этот браузер
|
||||
<Badge variant="success-light" size="xs">
|
||||
Активна
|
||||
</Badge>
|
||||
</ItemTitle>
|
||||
<ItemDescription className="leading-5">
|
||||
Tenant:{' '}
|
||||
<code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
)}
|
||||
</QueryState>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="Роль и доступ" description="Права текущего API-ключа">
|
||||
<SettingsFieldGroup
|
||||
legend="Роль"
|
||||
description="Уровень доступа, определённый API-ключом."
|
||||
>
|
||||
<SettingRow
|
||||
title="Роль"
|
||||
description="Определяет доступ к операциям control plane и CRUD."
|
||||
titleAddon={
|
||||
sessionQ.data ? (
|
||||
<Badge variant="info-light" size="sm">
|
||||
{ROLE_LABELS[sessionQ.data.role] ?? sessionQ.data.role}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" size="sm">
|
||||
неизвестно
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
last
|
||||
>
|
||||
{sessionQ.data ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Ключ с ролью <strong>{sessionQ.data.role}</strong> в tenant{' '}
|
||||
<code className="font-mono text-xs">{sessionQ.data.tenant_id}</code>.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Сохраните токен и дождитесь проверки сессии.
|
||||
</p>
|
||||
)}
|
||||
</SettingRow>
|
||||
</SettingsFieldGroup>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||
|
||||
import { AppearanceSettingsTab } from './appearance-settings-tab'
|
||||
import { ConnectionSettingsTab } from './connection-settings-tab'
|
||||
import { SectionsSettingsTab } from './sections-settings-tab'
|
||||
import { SessionSettingsTab } from './session-settings-tab'
|
||||
import {
|
||||
SETTINGS_TAB_ITEMS,
|
||||
type SettingsTab,
|
||||
} from './settings-tabs-data'
|
||||
|
||||
export function SettingsPageShell({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
tokenRequired,
|
||||
}: {
|
||||
activeTab: SettingsTab
|
||||
onTabChange: (tab: SettingsTab) => void
|
||||
tokenRequired: boolean
|
||||
}) {
|
||||
return (
|
||||
<BadgeTabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => onTabChange(value as SettingsTab)}
|
||||
items={SETTINGS_TAB_ITEMS.map((tab) => ({
|
||||
value: tab.value,
|
||||
label: tab.label,
|
||||
icon: tab.icon,
|
||||
}))}
|
||||
>
|
||||
<TabsContent value="connection" className="mt-0">
|
||||
<ConnectionSettingsTab tokenRequired={tokenRequired} />
|
||||
</TabsContent>
|
||||
<TabsContent value="session" className="mt-0">
|
||||
<SessionSettingsTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="appearance" className="mt-0">
|
||||
<AppearanceSettingsTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="sections" className="mt-0">
|
||||
<SectionsSettingsTab />
|
||||
</TabsContent>
|
||||
</BadgeTabs>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
KeyRoundIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
PaletteIcon,
|
||||
SlidersHorizontalIcon,
|
||||
} from 'lucide-react'
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
export type SettingsTab = 'connection' | 'session' | 'appearance' | 'sections'
|
||||
|
||||
export type SettingsTabItem = {
|
||||
value: SettingsTab
|
||||
label: string
|
||||
icon: ReactNode
|
||||
}
|
||||
|
||||
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
|
||||
{
|
||||
value: 'connection',
|
||||
label: 'Подключение',
|
||||
icon: <KeyRoundIcon aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
value: 'session',
|
||||
label: 'Сессия',
|
||||
icon: <MonitorSmartphoneIcon aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
value: 'appearance',
|
||||
label: 'Оформление',
|
||||
icon: <PaletteIcon aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
value: 'sections',
|
||||
label: 'Разделы',
|
||||
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
||||
},
|
||||
]
|
||||
|
||||
export function parseSettingsTab(value: unknown): SettingsTab {
|
||||
if (
|
||||
value === 'session' ||
|
||||
value === 'appearance' ||
|
||||
value === 'sections'
|
||||
) {
|
||||
return value
|
||||
}
|
||||
return 'connection'
|
||||
}
|
||||
@@ -1,35 +1,37 @@
|
||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
|
||||
import { KpiStatGrid } from '@/components/kpi-stat-grid'
|
||||
import { panelCardInsetClassName } from '@/components/panel-card'
|
||||
import {
|
||||
chartPanelGridClassName,
|
||||
dashboardKpiGridClassName,
|
||||
dashboardMainSidebarClassName,
|
||||
kpiGridClassName,
|
||||
} from '@/lib/ui-surface'
|
||||
import { SectionCards } from './section-cards'
|
||||
|
||||
export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
|
||||
export function KpiStatGridSkeleton({ count = 4 }: { count?: number }) {
|
||||
return (
|
||||
<SectionCards
|
||||
items={Array.from({ length: count }, (_, i) => ({
|
||||
icon: <Skeleton className="size-4 rounded-sm" key={`icon-${i}`} />,
|
||||
label: <Skeleton className="h-3 w-20" key={`label-${i}`} />,
|
||||
value: <Skeleton className="h-5 w-16" key={`value-${i}`} />,
|
||||
<KpiStatGrid
|
||||
items={Array.from({ length: count }, (_, index) => ({
|
||||
id: `kpi-skeleton-${index}`,
|
||||
icon: <Skeleton className="size-4 rounded-sm" />,
|
||||
value: <Skeleton className="h-8 w-16" />,
|
||||
label: <Skeleton className="h-4 w-28" />,
|
||||
footer: <Skeleton className="h-5 w-32 rounded-full" />,
|
||||
}))}
|
||||
aria-label="Загрузка показателей"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Matches dashboard-5 / telemetry layout: KPI row, charts, main+sidebar, bottom grids. */
|
||||
/** @deprecated Use KpiStatGridSkeleton */
|
||||
export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
|
||||
return <KpiStatGridSkeleton count={count} />
|
||||
}
|
||||
|
||||
/** Matches dashboard layout: KPI row, charts, main+sidebar, bottom grids. */
|
||||
export function AnalyticsDashboardSkeleton() {
|
||||
return (
|
||||
<div className="@container flex flex-col gap-4">
|
||||
<div className={dashboardKpiGridClassName}>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={`kpi-${i}`} className="h-28 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
<KpiStatGridSkeleton count={6} />
|
||||
|
||||
<div className={chartPanelGridClassName}>
|
||||
<Skeleton className="h-72 w-full rounded-xl" />
|
||||
@@ -50,13 +52,7 @@ export function AnalyticsDashboardSkeleton() {
|
||||
}
|
||||
|
||||
export function KpiSparklineSkeleton({ count = 4 }: { count?: number }) {
|
||||
return (
|
||||
<div className={kpiGridClassName}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-36 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
return <KpiStatGridSkeleton count={count} />
|
||||
}
|
||||
|
||||
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
|
||||
|
||||
Reference in New Issue
Block a user