feat(web): enhance integration settings and UI components
Build, Test, and Push CFDM Docker Image / test (push) Successful in 3m25s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 1m49s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Successful in 5s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped

- Updated AppSwitcherEditor to include item separators for better visual organization.
- Improved VpsTrackerIntegrationCard with badges indicating synchronization status and token configuration.
- Refactored SettingsShell to streamline layout and enhance accessibility with improved navigation.
- Added detail fields to KPI cards in DashboardPage for clearer information presentation.
- Introduced new components in the ReUI kit for better integration and user experience.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-10 01:10:34 +07:00
co-authored by Cursor
parent 1de497d4b4
commit 781c88175c
9 changed files with 462 additions and 325 deletions
@@ -4,9 +4,9 @@ import { PlusIcon, Trash2Icon } from 'lucide-react'
import { appSwitcherConfigSchema, type AppSwitcherConfig } from '@cfdm/shared'
import { Button } from '@cfdm/ui/components/button'
import { AppCard, AppCardContent, AppCardDescription, AppCardHeader, AppCardTitle } from '@/components/app-card'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import { ItemSeparator } from '@cfdm/ui/components/item'
import { FormFieldSimple } from '@/components/form-field'
import { SelectField } from '@/components/select-field'
import { LoadingButton } from '@/components/loading-button'
@@ -33,29 +33,31 @@ export function AppSwitcherEditor({ defaultValues, onSave, isSaving }: AppSwitch
const { fields, append, remove } = useFieldArray({ control: form.control, name: 'apps' })
return (
<AppCard>
<AppCardHeader>
<AppCardTitle>Связанные приложения</AppCardTitle>
<AppCardDescription>URL для переключателя в sidebar</AppCardDescription>
</AppCardHeader>
<AppCardContent>
<form
className="flex flex-col gap-4"
onSubmit={(e) => void form.handleSubmit((values) => onSave(values))(e)}
>
<FieldGroup>
<FormFieldSimple label="Заголовок меню" htmlFor="menu-label">
<Input id="menu-label" {...form.register('menuLabel')} />
</FormFieldSimple>
{fields.map((field, index) => (
<div key={field.id} className="grid gap-3 rounded-lg border p-3 md:grid-cols-2">
<form
className="flex flex-col gap-4"
onSubmit={(e) => void form.handleSubmit((values) => onSave(values))(e)}
>
<FieldGroup>
<FormFieldSimple label="Заголовок меню" htmlFor="menu-label">
<Input id="menu-label" {...form.register('menuLabel')} />
</FormFieldSimple>
<div className="flex flex-col gap-3">
{fields.map((field, index) => (
<div key={field.id}>
{index > 0 ? <ItemSeparator className="my-3" /> : null}
<div className="grid gap-3 sm:grid-cols-2">
<FormFieldSimple label="ID" htmlFor={`app-id-${index}`}>
<Input id={`app-id-${index}`} {...form.register(`apps.${index}.id`)} />
</FormFieldSimple>
<FormFieldSimple label="Название" htmlFor={`app-name-${index}`}>
<Input id={`app-name-${index}`} {...form.register(`apps.${index}.name`)} />
</FormFieldSimple>
<FormFieldSimple label="URL" htmlFor={`app-url-${index}`} className="md:col-span-2">
<FormFieldSimple
label="URL"
htmlFor={`app-url-${index}`}
className="sm:col-span-2"
>
<Input id={`app-url-${index}`} {...form.register(`apps.${index}.url`)} />
</FormFieldSimple>
<FormFieldSimple label="Иконка" htmlFor={`app-icon-${index}`}>
@@ -77,34 +79,42 @@ export function AppSwitcherEditor({ defaultValues, onSave, isSaving }: AppSwitch
size="icon"
disabled={fields.length <= 1}
onClick={() => remove(index)}
aria-label="Удалить приложение"
>
<Trash2Icon className="size-4" />
</Button>
</div>
</div>
))}
<Button
type="button"
variant="outline"
className="w-fit"
onClick={() =>
append({
id: `app-${fields.length + 1}`,
name: 'Приложение',
url: 'http://localhost:3000',
icon: 'server',
})
}
>
<PlusIcon data-icon="inline-start" />
Добавить приложение
</Button>
</FieldGroup>
<LoadingButton type="submit" className="w-fit" isLoading={isSaving} disabled={!form.formState.isDirty}>
Сохранить приложения
</LoadingButton>
</form>
</AppCardContent>
</AppCard>
</div>
))}
</div>
<Button
type="button"
variant="outline"
className="w-fit"
onClick={() =>
append({
id: `app-${fields.length + 1}`,
name: 'Приложение',
url: 'http://localhost:3000',
icon: 'server',
})
}
>
<PlusIcon data-icon="inline-start" />
Добавить приложение
</Button>
</FieldGroup>
<LoadingButton
type="submit"
className="w-fit"
isLoading={isSaving}
disabled={!form.formState.isDirty}
>
Сохранить приложения
</LoadingButton>
</form>
)
}
@@ -3,13 +3,13 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { AppCard, AppCardContent, AppCardDescription, AppCardHeader, AppCardTitle } from '@/components/app-card'
import { Badge } from '@/components/reui/badge'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import { FormFieldSimple } from '@/components/form-field'
import { SelectField } from '@/components/select-field'
import { LoadingButton } from '@/components/loading-button'
import { AppButton } from '@/components/app-button'
import { Button } from '@cfdm/ui/components/button'
import { api } from '@/lib/api-client'
const formSchema = z.object({
@@ -55,72 +55,81 @@ export function VpsTrackerIntegrationCard({
else toast.error(result.error ?? 'Ошибка проверки')
}
const syncBadgeVariant = settings?.vpsTrackerSyncEnabled ? 'success' : 'secondary'
return (
<AppCard>
<AppCardHeader>
<AppCardTitle>VPS Tracker</AppCardTitle>
<AppCardDescription>
Исходящая синхронизация доменов и сервисов. URL API VPS Tracker (обычно порт 3001).
</AppCardDescription>
</AppCardHeader>
<AppCardContent>
<form className="flex flex-col gap-4" onSubmit={(e) => void form.handleSubmit((values) => onSave(values))(e)}>
<FieldGroup>
<FormFieldSimple label="URL VPS Tracker" htmlFor="vps-url">
<Input
id="vps-url"
placeholder="http://192.168.100.67:3001"
{...form.register('vpsTrackerUrl')}
<form
className="flex flex-col gap-4"
onSubmit={(e) => void form.handleSubmit((values) => onSave(values))(e)}
>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={syncBadgeVariant}>
{settings?.vpsTrackerSyncEnabled ? 'Синхронизация включена' : 'Синхронизация выключена'}
</Badge>
{settings?.vpsTrackerIntegrationTokenSet ? (
<Badge variant="info">Токен настроен</Badge>
) : (
<Badge variant="warning">Токен не задан</Badge>
)}
</div>
<FieldGroup>
<FormFieldSimple label="URL VPS Tracker" htmlFor="vps-url">
<Input
id="vps-url"
placeholder="http://192.168.100.67:3001"
{...form.register('vpsTrackerUrl')}
/>
</FormFieldSimple>
<FormFieldSimple label="Integration token" htmlFor="vps-token">
<Input
id="vps-token"
type="password"
autoComplete="new-password"
placeholder={
settings?.vpsTrackerIntegrationTokenSet
? 'Токен установлен — введите новый для замены'
: 'Тот же токен, что в VPS Tracker'
}
{...form.register('vpsTrackerIntegrationToken')}
/>
</FormFieldSimple>
<Controller
control={form.control}
name="vpsTrackerSyncEnabled"
render={({ field }) => (
<FormFieldSimple label="Синхронизация" htmlFor="vps-sync">
<SelectField
triggerId="vps-sync"
triggerClassName="w-32"
value={field.value ? 'on' : 'off'}
onValueChange={(v) => field.onChange((v ?? 'on') === 'on')}
options={[
{ value: 'on', label: 'Вкл' },
{ value: 'off', label: 'Выкл' },
]}
/>
</FormFieldSimple>
<FormFieldSimple label="Integration token" htmlFor="vps-token">
<Input
id="vps-token"
type="password"
autoComplete="new-password"
placeholder={
settings?.vpsTrackerIntegrationTokenSet
? 'Токен установлен — введите новый для замены'
: 'Тот же токен, что в VPS Tracker'
}
{...form.register('vpsTrackerIntegrationToken')}
/>
</FormFieldSimple>
<Controller
control={form.control}
name="vpsTrackerSyncEnabled"
render={({ field }) => (
<FormFieldSimple label="Синхронизация включена" htmlFor="vps-sync">
<SelectField
triggerId="vps-sync"
triggerClassName="w-32"
value={field.value ? 'on' : 'off'}
onValueChange={(v) => field.onChange((v ?? 'on') === 'on')}
options={[
{ value: 'on', label: 'Вкл' },
{ value: 'off', label: 'Выкл' },
]}
/>
</FormFieldSimple>
)}
/>
{settings?.vpsTrackerLastSyncAt ? (
<p className="text-sm text-muted-foreground">
Последний sync:{' '}
{new Date(settings.vpsTrackerLastSyncAt).toLocaleString('ru-RU')}
</p>
) : null}
</FieldGroup>
<div className="flex flex-wrap gap-2">
<LoadingButton type="submit" isLoading={isSaving} disabled={!form.formState.isDirty}>
Сохранить
</LoadingButton>
<AppButton type="button" variant="outline" onClick={() => void handleTest()}>
Проверить связь
</AppButton>
</div>
</form>
</AppCardContent>
</AppCard>
)}
/>
{settings?.vpsTrackerLastSyncAt ? (
<p className="text-muted-foreground text-sm">
Последний sync:{' '}
{new Date(settings.vpsTrackerLastSyncAt).toLocaleString('ru-RU')}
</p>
) : (
<p className="text-muted-foreground text-sm">Синхронизация ещё не выполнялась</p>
)}
</FieldGroup>
<div className="flex flex-wrap gap-2">
<LoadingButton type="submit" isLoading={isSaving} disabled={!form.formState.isDirty}>
Сохранить
</LoadingButton>
<Button type="button" variant="outline" onClick={() => void handleTest()}>
Проверить связь
</Button>
</div>
</form>
)
}