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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user