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 { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
import { cn } from '@cfdm/ui/lib/utils' import { cn } from '@cfdm/ui/lib/utils'
import { PageShell } from '@/components/page-shell' import { PageShell } from '@/components/page-shell'
import { Separator } from '@cfdm/ui/components/separator'
export interface SettingsTabConfig { export interface SettingsTabConfig {
id: string id: string
@@ -38,19 +39,20 @@ export function SettingsShell({
return ( return (
<PageShell> <PageShell>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6"> <div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
<header className="px-1"> <header className="flex flex-col gap-3 px-1">
<div className="flex flex-col gap-px"> <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"> <p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
{description} {description}
</p> </p>
</div> </div>
<Separator />
</header> </header>
<div <div
className={cn( className={cn(
'flex gap-6', 'flex gap-5',
isMobile ? 'flex-col' : 'flex-row items-start', isMobile ? 'flex-col' : 'flex-row items-start',
)} )}
> >
@@ -1,6 +1,7 @@
import { useState, type ReactNode } from 'react'
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' 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 { toast } from 'sonner'
import type { AppSettingsPatch } from '@cfdm/shared' import type { AppSettingsPatch } from '@cfdm/shared'
@@ -13,6 +14,7 @@ import {
} from '@/components/integrations/vps-tracker-integration-card' } from '@/components/integrations/vps-tracker-integration-card'
import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config' import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config'
import { appSwitcherQueryKey } from '@/queries/app-switcher' import { appSwitcherQueryKey } from '@/queries/app-switcher'
import { Badge } from '@/components/reui/badge'
import { import {
Frame, Frame,
FrameDescription, FrameDescription,
@@ -20,20 +22,123 @@ import {
FramePanel, FramePanel,
FrameTitle, FrameTitle,
} from '@/components/reui/frame' } from '@/components/reui/frame'
import { Item, ItemMedia } from '@cfdm/ui/components/item' import { Button } from '@cfdm/ui/components/button'
import { ServerIcon } from 'lucide-react' 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 & { type SettingsResponse = AppSettingsView & {
id: string id: string
appSwitcher: typeof DEFAULT_APP_SWITCHER_CONFIG 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')({ export const Route = createFileRoute('/_auth/settings/integrations')({
component: SettingsIntegrationsPage, 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() { function SettingsIntegrationsPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [openAppSwitcher, setOpenAppSwitcher] = useState(true)
const [openVpsTracker, setOpenVpsTracker] = useState(true)
const { data, isLoading, isError, error, refetch } = useQuery({ const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ['app-settings'], queryKey: ['app-settings'],
queryFn: () => api.get<SettingsResponse>('/api/v1/settings'), queryFn: () => api.get<SettingsResponse>('/api/v1/settings'),
@@ -50,6 +155,18 @@ function SettingsIntegrationsPage() {
onError: () => toast.error('Не удалось сохранить'), 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 ( return (
<QueryState <QueryState
isLoading={isLoading} isLoading={isLoading}
@@ -68,25 +185,30 @@ function SettingsIntegrationsPage() {
</div> </div>
<Frame> <Frame>
<FrameHeader className="flex-row items-center gap-3"> <FrameHeader className="sr-only">
<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"> <FrameTitle>App Switcher</FrameTitle>
<ItemMedia variant="icon" className="size-auto"> <FrameDescription>
<LayoutGridIcon aria-hidden="true" /> Быстрый переход между связанными приложениями
</ItemMedia> </FrameDescription>
</Item>
<div className="min-w-0">
<FrameTitle>App Switcher</FrameTitle>
<FrameDescription>
Быстрый переход между связанными приложениями
</FrameDescription>
</div>
</FrameHeader> </FrameHeader>
<FramePanel> <FramePanel className="p-0!">
<AppSwitcherEditor <ItemGroup className="gap-0">
defaultValues={data.appSwitcher ?? DEFAULT_APP_SWITCHER_CONFIG} <IntegrationRow
isSaving={saveMut.isPending} id="app-switcher"
onSave={(appSwitcher) => saveMut.mutate({ appSwitcher })} 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> </FramePanel>
</Frame> </Frame>
</section> </section>
@@ -100,31 +222,40 @@ function SettingsIntegrationsPage() {
</div> </div>
<Frame> <Frame>
<FrameHeader className="flex-row items-center gap-3"> <FrameHeader className="sr-only">
<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"> <FrameTitle>VPS Tracker</FrameTitle>
<ItemMedia variant="icon" className="size-auto"> <FrameDescription>URL, токен и расписание синхронизации</FrameDescription>
<ServerIcon aria-hidden="true" />
</ItemMedia>
</Item>
<div className="min-w-0">
<FrameTitle>VPS Tracker</FrameTitle>
<FrameDescription>URL, токен и расписание синхронизации</FrameDescription>
</div>
</FrameHeader> </FrameHeader>
<FramePanel> <FramePanel className="p-0!">
<VpsTrackerIntegrationCard <ItemGroup className="gap-0">
settings={data} <IntegrationRow
isSaving={saveMut.isPending} id="vps-tracker"
onSave={(values) => { name="VPS Tracker"
const patch: AppSettingsPatch = { description={
vpsTrackerUrl: values.vpsTrackerUrl, data.vpsTrackerUrl
vpsTrackerSyncEnabled: values.vpsTrackerSyncEnabled, ? data.vpsTrackerUrl
: 'URL, токен и расписание синхронизации'
} }
const token = values.vpsTrackerIntegrationToken?.trim() logo={<ServerIcon aria-hidden="true" />}
if (token) patch.vpsTrackerIntegrationToken = token status={vpsStatus}
saveMut.mutate(patch) 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> </FramePanel>
</Frame> </Frame>
</section> </section>