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