feat(web): перевести настройки, VPS-детали и журнал на вкладки из дизайна VPS-Tracker

Настройки — line-вкладки сверху вместо боковой навигации, детали VPS —
line-вкладки и действия в шапке, журнал — общие CountedLineTabs, шапка
страницы 20px. Детальные маршруты VPS и проекта вынесены из вложенности
(vps_.$vpsId, projects_.$projectId): списки не рендерят Outlet, и детали
не открывались.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-11-06 22:02:04 +07:00
1 parent b5300b9082
commit e3403bcb56
6 files changed
+102 -148

No files matched your search

+4 -4
View File
@@ -6,12 +6,12 @@ interface PageHeaderProps {
actions?: ReactNode
}
/** Page-level section header — etalon EvoBGP. */
/** Page header — design «VPS-Tracker»: 20px title + muted sub, actions bottom-aligned right. */
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div className="flex flex-col gap-0.5">
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
{description ? (
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
@@ -7,11 +7,9 @@ import {
SettingsIcon,
} from 'lucide-react'
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
import { cn } from '@cfdm/ui/lib/utils'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { Separator } from '@cfdm/ui/components/separator'
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
export interface SettingsTabConfig {
id: string
@@ -55,69 +53,42 @@ interface SettingsShellProps {
tabs?: SettingsTabConfig[]
}
/** Settings shell — preview https://reui.io/preview/base/settings-16 */
/** Settings shell — line tabs on top (design «VPS-Tracker»); sections: https://reui.io/preview/base/settings-16 */
export function SettingsShell({
title = 'Настройки',
description = 'Параметры приложения и связи с другими сервисами',
tabs = DEFAULT_TABS,
}: SettingsShellProps) {
const isMobile = useIsMobile()
const pathname = useRouterState({ select: (s) => s.location.pathname })
const active =
tabs.find((tab) =>
tab.exact ? pathname === tab.to || pathname === `${tab.to}/` : pathname.startsWith(tab.to),
) ?? tabs[0]
return (
<PageShell>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
<div className="flex flex-col gap-3">
<PageHeader title={title} description={description} />
<Separator />
</div>
<div
className={cn(
'flex gap-5',
isMobile ? 'flex-col' : 'flex-row items-start',
)}
>
{tabs.length > 1 ? (
<nav
aria-label="Разделы настроек"
className={cn(
'flex gap-1',
isMobile
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
: 'w-44 shrink-0 flex-col',
)}
>
{tabs.map((tab) => {
const isActive = tab.exact
? pathname === tab.to || pathname === `${tab.to}/`
: pathname.startsWith(tab.to)
return (
<Link
key={tab.id}
to={tab.to}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
isMobile && 'shrink-0',
!isMobile && 'w-full',
isActive
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
)}
>
{tab.icon}
{tab.label}
</Link>
)
})}
</nav>
) : null}
<div className="min-w-0 flex-1">
<Outlet />
</div>
</div>
<PageHeader title={title} description={description} />
{/* Design «VPS-Tracker»: settings sections as page line tabs. */}
{tabs.length > 1 ? (
<Tabs value={active?.id}>
<TabsList variant="line" aria-label="Разделы настроек" className="gap-5">
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
nativeButton={false}
render={<Link to={tab.to} />}
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
{tab.icon}
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
<div className="min-w-0">
<Outlet />
</div>
</PageShell>
)
+34 -53
View File
@@ -31,11 +31,11 @@ import { Route as AuthAuditRouteImport } from './routes/_auth/audit'
import { Route as AuthAccountsRouteImport } from './routes/_auth/accounts'
import { Route as AuthSettingsRouteRouteImport } from './routes/_auth/settings/route'
import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/index'
import { Route as AuthVpsVpsIdRouteImport } from './routes/_auth/vps.$vpsId'
import { Route as AuthVpsVpsIdRouteImport } from './routes/_auth/vps_.$vpsId'
import { Route as AuthSettingsSyncRouteImport } from './routes/_auth/settings/sync'
import { Route as AuthSettingsNotificationsRouteImport } from './routes/_auth/settings/notifications'
import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations'
import { Route as AuthProjectsProjectIdRouteImport } from './routes/_auth/projects.$projectId'
import { Route as AuthProjectsProjectIdRouteImport } from './routes/_auth/projects_.$projectId'
const AuthRoute = AuthRouteImport.update({
id: '/_auth',
@@ -147,9 +147,9 @@ const AuthSettingsIndexRoute = AuthSettingsIndexRouteImport.update({
getParentRoute: () => AuthSettingsRouteRoute,
} as any)
const AuthVpsVpsIdRoute = AuthVpsVpsIdRouteImport.update({
id: '/$vpsId',
path: '/$vpsId',
getParentRoute: () => AuthVpsRoute,
id: '/vps_/$vpsId',
path: '/vps/$vpsId',
getParentRoute: () => AuthRoute,
} as any)
const AuthSettingsSyncRoute = AuthSettingsSyncRouteImport.update({
id: '/sync',
@@ -169,9 +169,9 @@ const AuthSettingsIntegrationsRoute =
getParentRoute: () => AuthSettingsRouteRoute,
} as any)
const AuthProjectsProjectIdRoute = AuthProjectsProjectIdRouteImport.update({
id: '/$projectId',
path: '/$projectId',
getParentRoute: () => AuthProjectsRoute,
id: '/projects_/$projectId',
path: '/projects/$projectId',
getParentRoute: () => AuthRoute,
} as any)
export interface FileRoutesByFullPath {
@@ -184,7 +184,7 @@ export interface FileRoutesByFullPath {
'/dashboard': typeof AuthDashboardRoute
'/geo': typeof AuthGeoRoute
'/payments': typeof AuthPaymentsRoute
'/projects': typeof AuthProjectsRouteWithChildren
'/projects': typeof AuthProjectsRoute
'/providers': typeof AuthProvidersRoute
'/renewals': typeof AuthRenewalsRoute
'/reports': typeof AuthReportsRoute
@@ -193,7 +193,7 @@ export interface FileRoutesByFullPath {
'/sync-journal': typeof AuthSyncJournalRoute
'/tariffs': typeof AuthTariffsRoute
'/topology': typeof AuthTopologyRoute
'/vps': typeof AuthVpsRouteWithChildren
'/vps': typeof AuthVpsRoute
'/auth/callback': typeof AuthCallbackRoute
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
@@ -211,7 +211,7 @@ export interface FileRoutesByTo {
'/dashboard': typeof AuthDashboardRoute
'/geo': typeof AuthGeoRoute
'/payments': typeof AuthPaymentsRoute
'/projects': typeof AuthProjectsRouteWithChildren
'/projects': typeof AuthProjectsRoute
'/providers': typeof AuthProvidersRoute
'/renewals': typeof AuthRenewalsRoute
'/reports': typeof AuthReportsRoute
@@ -220,7 +220,7 @@ export interface FileRoutesByTo {
'/sync-journal': typeof AuthSyncJournalRoute
'/tariffs': typeof AuthTariffsRoute
'/topology': typeof AuthTopologyRoute
'/vps': typeof AuthVpsRouteWithChildren
'/vps': typeof AuthVpsRoute
'/auth/callback': typeof AuthCallbackRoute
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
@@ -241,7 +241,7 @@ export interface FileRoutesById {
'/_auth/dashboard': typeof AuthDashboardRoute
'/_auth/geo': typeof AuthGeoRoute
'/_auth/payments': typeof AuthPaymentsRoute
'/_auth/projects': typeof AuthProjectsRouteWithChildren
'/_auth/projects': typeof AuthProjectsRoute
'/_auth/providers': typeof AuthProvidersRoute
'/_auth/renewals': typeof AuthRenewalsRoute
'/_auth/reports': typeof AuthReportsRoute
@@ -250,13 +250,13 @@ export interface FileRoutesById {
'/_auth/sync-journal': typeof AuthSyncJournalRoute
'/_auth/tariffs': typeof AuthTariffsRoute
'/_auth/topology': typeof AuthTopologyRoute
'/_auth/vps': typeof AuthVpsRouteWithChildren
'/_auth/vps': typeof AuthVpsRoute
'/auth/callback': typeof AuthCallbackRoute
'/_auth/projects/$projectId': typeof AuthProjectsProjectIdRoute
'/_auth/projects_/$projectId': typeof AuthProjectsProjectIdRoute
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/_auth/settings/notifications': typeof AuthSettingsNotificationsRoute
'/_auth/settings/sync': typeof AuthSettingsSyncRoute
'/_auth/vps/$vpsId': typeof AuthVpsVpsIdRoute
'/_auth/vps_/$vpsId': typeof AuthVpsVpsIdRoute
'/_auth/settings/': typeof AuthSettingsIndexRoute
}
export interface FileRouteTypes {
@@ -338,11 +338,11 @@ export interface FileRouteTypes {
| '/_auth/topology'
| '/_auth/vps'
| '/auth/callback'
| '/_auth/projects/$projectId'
| '/_auth/projects_/$projectId'
| '/_auth/settings/integrations'
| '/_auth/settings/notifications'
| '/_auth/settings/sync'
| '/_auth/vps/$vpsId'
| '/_auth/vps_/$vpsId'
| '/_auth/settings/'
fileRoutesById: FileRoutesById
}
@@ -508,12 +508,12 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthSettingsIndexRouteImport
parentRoute: typeof AuthSettingsRouteRoute
}
'/_auth/vps/$vpsId': {
id: '/_auth/vps/$vpsId'
path: '/$vpsId'
'/_auth/vps_/$vpsId': {
id: '/_auth/vps_/$vpsId'
path: '/vps/$vpsId'
fullPath: '/vps/$vpsId'
preLoaderRoute: typeof AuthVpsVpsIdRouteImport
parentRoute: typeof AuthVpsRoute
parentRoute: typeof AuthRoute
}
'/_auth/settings/sync': {
id: '/_auth/settings/sync'
@@ -536,12 +536,12 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport
parentRoute: typeof AuthSettingsRouteRoute
}
'/_auth/projects/$projectId': {
id: '/_auth/projects/$projectId'
path: '/$projectId'
'/_auth/projects_/$projectId': {
id: '/_auth/projects_/$projectId'
path: '/projects/$projectId'
fullPath: '/projects/$projectId'
preLoaderRoute: typeof AuthProjectsProjectIdRouteImport
parentRoute: typeof AuthProjectsRoute
parentRoute: typeof AuthRoute
}
}
}
@@ -563,29 +563,6 @@ const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = {
const AuthSettingsRouteRouteWithChildren =
AuthSettingsRouteRoute._addFileChildren(AuthSettingsRouteRouteChildren)
interface AuthProjectsRouteChildren {
AuthProjectsProjectIdRoute: typeof AuthProjectsProjectIdRoute
}
const AuthProjectsRouteChildren: AuthProjectsRouteChildren = {
AuthProjectsProjectIdRoute: AuthProjectsProjectIdRoute,
}
const AuthProjectsRouteWithChildren = AuthProjectsRoute._addFileChildren(
AuthProjectsRouteChildren,
)
interface AuthVpsRouteChildren {
AuthVpsVpsIdRoute: typeof AuthVpsVpsIdRoute
}
const AuthVpsRouteChildren: AuthVpsRouteChildren = {
AuthVpsVpsIdRoute: AuthVpsVpsIdRoute,
}
const AuthVpsRouteWithChildren =
AuthVpsRoute._addFileChildren(AuthVpsRouteChildren)
interface AuthRouteChildren {
AuthSettingsRouteRoute: typeof AuthSettingsRouteRouteWithChildren
AuthAccountsRoute: typeof AuthAccountsRoute
@@ -595,7 +572,7 @@ interface AuthRouteChildren {
AuthDashboardRoute: typeof AuthDashboardRoute
AuthGeoRoute: typeof AuthGeoRoute
AuthPaymentsRoute: typeof AuthPaymentsRoute
AuthProjectsRoute: typeof AuthProjectsRouteWithChildren
AuthProjectsRoute: typeof AuthProjectsRoute
AuthProvidersRoute: typeof AuthProvidersRoute
AuthRenewalsRoute: typeof AuthRenewalsRoute
AuthReportsRoute: typeof AuthReportsRoute
@@ -604,7 +581,9 @@ interface AuthRouteChildren {
AuthSyncJournalRoute: typeof AuthSyncJournalRoute
AuthTariffsRoute: typeof AuthTariffsRoute
AuthTopologyRoute: typeof AuthTopologyRoute
AuthVpsRoute: typeof AuthVpsRouteWithChildren
AuthVpsRoute: typeof AuthVpsRoute
AuthProjectsProjectIdRoute: typeof AuthProjectsProjectIdRoute
AuthVpsVpsIdRoute: typeof AuthVpsVpsIdRoute
}
const AuthRouteChildren: AuthRouteChildren = {
@@ -616,7 +595,7 @@ const AuthRouteChildren: AuthRouteChildren = {
AuthDashboardRoute: AuthDashboardRoute,
AuthGeoRoute: AuthGeoRoute,
AuthPaymentsRoute: AuthPaymentsRoute,
AuthProjectsRoute: AuthProjectsRouteWithChildren,
AuthProjectsRoute: AuthProjectsRoute,
AuthProvidersRoute: AuthProvidersRoute,
AuthRenewalsRoute: AuthRenewalsRoute,
AuthReportsRoute: AuthReportsRoute,
@@ -625,7 +604,9 @@ const AuthRouteChildren: AuthRouteChildren = {
AuthSyncJournalRoute: AuthSyncJournalRoute,
AuthTariffsRoute: AuthTariffsRoute,
AuthTopologyRoute: AuthTopologyRoute,
AuthVpsRoute: AuthVpsRouteWithChildren,
AuthVpsRoute: AuthVpsRoute,
AuthProjectsProjectIdRoute: AuthProjectsProjectIdRoute,
AuthVpsVpsIdRoute: AuthVpsVpsIdRoute,
}
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
+10 -17
View File
@@ -24,7 +24,7 @@ import {
} from '@/components/domain/audit-labels'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@/components/reui/badge'
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { TableSkeleton } from '@/components/skeletons'
const auditSearchSchema = z.object({
@@ -197,22 +197,15 @@ function AuditPage() {
actions={<AuditViewToggle view={view} onViewChange={setView} />}
/>
<Tabs value={actionFilter} onValueChange={setActionFilter}>
<TabsList variant="line" aria-label="Фильтр по действию" className="gap-5">
{ACTION_FILTERS.map((option) => (
<TabsTrigger
key={option.value}
value={option.value}
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3"
>
<span>{option.label}</span>
<span className="bg-muted text-muted-foreground rounded-md px-1.5 py-0.5 text-[10px] tabular-nums">
{actionCounts[option.value]}
</span>
</TabsTrigger>
))}
</TabsList>
</Tabs>
<CountedLineTabs
tabs={ACTION_FILTERS.map((option) => ({
id: option.value,
label: option.label,
count: actionCounts[option.value],
}))}
value={actionFilter}
onValueChange={setActionFilter}
/>
<QueryState
data={data}
@@ -51,7 +51,7 @@ import {
} from '@/lib/project-analytics'
import type { Vps } from '@/types/entities'
export const Route = createFileRoute('/_auth/projects/$projectId')({
export const Route = createFileRoute('/_auth/projects_/$projectId')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
component: ProjectDetailPage,
@@ -48,7 +48,7 @@ import {
import type { Payment, Vps } from '@/types/entities'
import { VpsDomainsCell } from '@/components/integrations/vps-domains-cell'
export const Route = createFileRoute('/_auth/vps/$vpsId')({
export const Route = createFileRoute('/_auth/vps_/$vpsId')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
component: VpsDetailPage,
@@ -153,19 +153,18 @@ function VpsDetailPage() {
title={vps ? (vps.ip || vps.dns || 'VPS') : 'VPS'}
description={account ? accountSelectLabel(account, providerById) : undefined}
actions={
vps ? (
<Button variant="outline" render={<Link to="/vps" search={{ edit: vpsId }} />}>
Редактировать
<>
<Button variant="outline" render={<Link to="/vps" />}>
<ArrowLeftIcon data-icon="inline-start" />
К списку VPS
</Button>
) : undefined
{vps ? (
<Button render={<Link to="/vps" search={{ edit: vpsId }} />}>Редактировать</Button>
) : null}
</>
}
/>
<Button variant="ghost" size="sm" className="w-fit" render={<Link to="/vps" />}>
<ArrowLeftIcon data-icon="inline-start" />
К списку VPS
</Button>
<QueryState
data={vps}
isLoading={isLoading}
@@ -189,12 +188,22 @@ function VpsDetailPage() {
}
>
{(row) => (
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Обзор</TabsTrigger>
<TabsTrigger value="finance">Финансы</TabsTrigger>
<TabsTrigger value="notes">Заметки</TabsTrigger>
<TabsTrigger value="sync">Синк</TabsTrigger>
<Tabs defaultValue="overview" className="flex flex-col gap-4 md:gap-6">
<TabsList variant="line" className="gap-5">
{[
['overview', 'Обзор'],
['finance', 'Финансы'],
['notes', 'Заметки'],
['sync', 'Синк'],
].map(([value, label]) => (
<TabsTrigger
key={value}
value={value}
className="text-muted-foreground hover:text-foreground h-auto px-0 pb-3 after:bottom-0"
>
{label}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="overview" className="flex flex-col gap-4">