feat(settings): адаптировать integrations под ReUI settings-16

Item-rows с status Badge и Collapsible-формами для App Switcher и VPS Tracker; SettingsShell — spacing/header в духе settings-9.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-17 13:46:28 +07:00
co-authored by Cursor
parent 1e8236298d
commit a15b52df95
2 changed files with 181 additions and 48 deletions
@@ -5,6 +5,7 @@ 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 { Separator } from '@cfdm/ui/components/separator'
export interface SettingsTabConfig {
id: string
@@ -38,19 +39,20 @@ export function SettingsShell({
return (
<PageShell>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
<header className="px-1">
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
<header className="flex flex-col gap-3 px-1">
<div className="flex flex-col gap-px">
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
{description}
</p>
</div>
<Separator />
</header>
<div
className={cn(
'flex gap-6',
'flex gap-5',
isMobile ? 'flex-col' : 'flex-row items-start',
)}
>
@@ -1,6 +1,7 @@
import { useState, type ReactNode } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { LayoutGridIcon } from 'lucide-react'
import { ChevronDownIcon, LayoutGridIcon, ServerIcon } from 'lucide-react'
import { toast } from 'sonner'
import type { AppSettingsPatch } from '@cfdm/shared'
@@ -13,6 +14,7 @@ import {
} from '@/components/integrations/vps-tracker-integration-card'
import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config'
import { appSwitcherQueryKey } from '@/queries/app-switcher'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameDescription,
@@ -20,20 +22,123 @@ import {
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Item, ItemMedia } from '@cfdm/ui/components/item'
import { ServerIcon } from 'lucide-react'
import { Button } from '@cfdm/ui/components/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@cfdm/ui/components/collapsible'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from '@cfdm/ui/components/item'
import { cn } from '@cfdm/ui/lib/utils'
type SettingsResponse = AppSettingsView & {
id: string
appSwitcher: typeof DEFAULT_APP_SWITCHER_CONFIG
}
type IntegrationStatus = 'connected' | 'available' | 'warning'
const STATUS_META: Record<
IntegrationStatus,
{ label: string; variant: 'success-light' | 'secondary' | 'warning-light' }
> = {
connected: { label: 'Подключено', variant: 'success-light' },
available: { label: 'Доступно', variant: 'secondary' },
warning: { label: 'Требует настройки', variant: 'warning-light' },
}
export const Route = createFileRoute('/_auth/settings/integrations')({
component: SettingsIntegrationsPage,
})
function IntegrationLogo({ children }: { children: ReactNode }) {
return (
<Item className="bg-muted/60 border-background flex size-10 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:block [&_svg]:size-5">
<ItemMedia variant="icon" className="size-auto translate-y-0! self-center!">
{children}
</ItemMedia>
</Item>
)
}
function IntegrationRow({
id,
name,
description,
logo,
status,
open,
onOpenChange,
children,
}: {
id: string
name: string
description: string
logo: ReactNode
status: IntegrationStatus
open: boolean
onOpenChange: (open: boolean) => void
children: ReactNode
}) {
const meta = STATUS_META[status]
return (
<Collapsible open={open} onOpenChange={onOpenChange}>
<Item role="listitem" className="items-center px-3.5 py-2.5 sm:px-4">
<ItemMedia className="translate-y-0! self-center!">
<IntegrationLogo>{logo}</IntegrationLogo>
</ItemMedia>
<ItemContent className="min-w-0 gap-0">
<ItemTitle className="w-full min-w-0 gap-2">
<span className="truncate">{name}</span>
<Badge variant={meta.variant}>{meta.label}</Badge>
</ItemTitle>
<ItemDescription className="line-clamp-1">{description}</ItemDescription>
</ItemContent>
<ItemActions className="ml-auto shrink-0 justify-end gap-2">
<CollapsibleTrigger
render={
<Button
type="button"
variant="outline"
size="sm"
aria-controls={`${id}-panel`}
aria-expanded={open}
/>
}
>
Настроить
<ChevronDownIcon
aria-hidden="true"
data-icon="inline-end"
className={cn('transition-transform', open && 'rotate-180')}
/>
</CollapsibleTrigger>
</ItemActions>
</Item>
<CollapsibleContent id={`${id}-panel`}>
<div className="border-border/60 border-t px-3.5 py-4 sm:px-4">{children}</div>
</CollapsibleContent>
</Collapsible>
)
}
function SettingsIntegrationsPage() {
const queryClient = useQueryClient()
const [openAppSwitcher, setOpenAppSwitcher] = useState(true)
const [openVpsTracker, setOpenVpsTracker] = useState(true)
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ['app-settings'],
queryFn: () => api.get<SettingsResponse>('/api/v1/settings'),
@@ -50,6 +155,18 @@ function SettingsIntegrationsPage() {
onError: () => toast.error('Не удалось сохранить'),
})
const appSwitcher = data?.appSwitcher ?? DEFAULT_APP_SWITCHER_CONFIG
const appSwitcherStatus: IntegrationStatus =
appSwitcher.apps.length > 0 ? 'connected' : 'available'
const vpsStatus: IntegrationStatus = !data
? 'available'
: data.vpsTrackerSyncEnabled && data.vpsTrackerIntegrationTokenSet
? 'connected'
: data.vpsTrackerUrl || data.vpsTrackerIntegrationTokenSet
? 'warning'
: 'available'
return (
<QueryState
isLoading={isLoading}
@@ -68,25 +185,30 @@ function SettingsIntegrationsPage() {
</div>
<Frame>
<FrameHeader className="flex-row items-center gap-3">
<Item className="bg-muted/60 border-background flex size-10 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5">
<ItemMedia variant="icon" className="size-auto">
<LayoutGridIcon aria-hidden="true" />
</ItemMedia>
</Item>
<div className="min-w-0">
<FrameTitle>App Switcher</FrameTitle>
<FrameDescription>
Быстрый переход между связанными приложениями
</FrameDescription>
</div>
<FrameHeader className="sr-only">
<FrameTitle>App Switcher</FrameTitle>
<FrameDescription>
Быстрый переход между связанными приложениями
</FrameDescription>
</FrameHeader>
<FramePanel>
<AppSwitcherEditor
defaultValues={data.appSwitcher ?? DEFAULT_APP_SWITCHER_CONFIG}
isSaving={saveMut.isPending}
onSave={(appSwitcher) => saveMut.mutate({ appSwitcher })}
/>
<FramePanel className="p-0!">
<ItemGroup className="gap-0">
<IntegrationRow
id="app-switcher"
name="App Switcher"
description={`${appSwitcher.apps.length} приложений · меню «${appSwitcher.menuLabel}»`}
logo={<LayoutGridIcon aria-hidden="true" />}
status={appSwitcherStatus}
open={openAppSwitcher}
onOpenChange={setOpenAppSwitcher}
>
<AppSwitcherEditor
defaultValues={appSwitcher}
isSaving={saveMut.isPending}
onSave={(next) => saveMut.mutate({ appSwitcher: next })}
/>
</IntegrationRow>
</ItemGroup>
</FramePanel>
</Frame>
</section>
@@ -100,31 +222,40 @@ function SettingsIntegrationsPage() {
</div>
<Frame>
<FrameHeader className="flex-row items-center gap-3">
<Item className="bg-muted/60 border-background flex size-10 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5">
<ItemMedia variant="icon" className="size-auto">
<ServerIcon aria-hidden="true" />
</ItemMedia>
</Item>
<div className="min-w-0">
<FrameTitle>VPS Tracker</FrameTitle>
<FrameDescription>URL, токен и расписание синхронизации</FrameDescription>
</div>
<FrameHeader className="sr-only">
<FrameTitle>VPS Tracker</FrameTitle>
<FrameDescription>URL, токен и расписание синхронизации</FrameDescription>
</FrameHeader>
<FramePanel>
<VpsTrackerIntegrationCard
settings={data}
isSaving={saveMut.isPending}
onSave={(values) => {
const patch: AppSettingsPatch = {
vpsTrackerUrl: values.vpsTrackerUrl,
vpsTrackerSyncEnabled: values.vpsTrackerSyncEnabled,
<FramePanel className="p-0!">
<ItemGroup className="gap-0">
<IntegrationRow
id="vps-tracker"
name="VPS Tracker"
description={
data.vpsTrackerUrl
? data.vpsTrackerUrl
: 'URL, токен и расписание синхронизации'
}
const token = values.vpsTrackerIntegrationToken?.trim()
if (token) patch.vpsTrackerIntegrationToken = token
saveMut.mutate(patch)
}}
/>
logo={<ServerIcon aria-hidden="true" />}
status={vpsStatus}
open={openVpsTracker}
onOpenChange={setOpenVpsTracker}
>
<VpsTrackerIntegrationCard
settings={data}
isSaving={saveMut.isPending}
onSave={(values) => {
const patch: AppSettingsPatch = {
vpsTrackerUrl: values.vpsTrackerUrl,
vpsTrackerSyncEnabled: values.vpsTrackerSyncEnabled,
}
const token = values.vpsTrackerIntegrationToken?.trim()
if (token) patch.vpsTrackerIntegrationToken = token
saveMut.mutate(patch)
}}
/>
</IntegrationRow>
</ItemGroup>
</FramePanel>
</Frame>
</section>