feat(design-preview): страница «Ссылки приложений» по канвасу на блоках ReUI PRO

Порядок с номером и ручкой, плитки иконок, переключатель «В меню» в строке, меню строки
(в начало, выше, ниже, в конец), сброс порядка, вкладка «Меню» в стиле settings-10 с
предпросмотром, форма приложения с выбором иконки и режима входа.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-18 01:47:10 +07:00
1 parent 1a46aaa6a5
commit a55d79c974
1 file changed
+251 -59
@@ -1,14 +1,47 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ArrowDownIcon, ArrowUpIcon, PlusIcon, LayoutGridIcon, EyeIcon, KeyRoundIcon, FingerprintIcon } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import {
ChartColumnIcon,
CloudIcon,
EyeIcon,
FingerprintIcon,
GripVerticalIcon,
KeyRoundIcon,
LayoutDashboardIcon,
LayoutGridIcon,
MoreHorizontalIcon,
NetworkIcon,
PencilIcon,
PlusIcon,
RotateCcwIcon,
ServerIcon,
type LucideIcon,
} from 'lucide-react'
import { toast } from 'sonner'
import { APPS, type AppId } from '@authportal/shared'
import { AppSwitcherAdminEditor } from '@/components/reui-kit/app-switcher-admin-editor'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
import type { AppId, AppSwitcherIconName } from '@authportal/shared'
import { QuickActionGrid } from '@/components/reui-kit/quick-action-grid'
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { Button } from '@authportal/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field'
import { Input } from '@authportal/ui/components/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@authportal/ui/components/select'
import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid'
import { PageHead, PreviewSheet, StatusBadge } from '../bits'
import { kpi } from '../kpi'
import { MOCK_SWITCHER } from '../mock'
@@ -19,11 +52,24 @@ type LinkRow = {
id: AppId
name: string
url: string
icon: string
icon: AppSwitcherIconName
mode: 'JWT' | 'OIDC'
enabled: boolean
}
const ICONS: Record<AppSwitcherIconName, { label: string; Icon: LucideIcon }> = {
cloud: { label: 'Облако', Icon: CloudIcon },
server: { label: 'Сервер', Icon: ServerIcon },
globe: { label: 'Сеть', Icon: NetworkIcon },
dashboard: { label: 'Дашборд', Icon: LayoutDashboardIcon },
chart: { label: 'График', Icon: ChartColumnIcon },
}
const MODES = [
{ value: 'JWT', label: 'JWT SSO (fragment)' },
{ value: 'OIDC', label: 'OIDC' },
] as const
const INITIAL: LinkRow[] = MOCK_SWITCHER.apps.map((a) => ({
id: a.id,
name: a.name,
@@ -33,6 +79,19 @@ const INITIAL: LinkRow[] = MOCK_SWITCHER.apps.map((a) => ({
enabled: a.id !== 'mm',
}))
/** Строка настройки в Frame: слева название и описание, справа элемент управления (settings-10). */
function SettingRow({ title, description, children }: { title: string; description: string; children: ReactNode }) {
return (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b px-4 py-3 last:border-b-0">
<div className="flex min-w-0 flex-1 basis-60 flex-col gap-0.5">
<span className="text-sm font-medium">{title}</span>
<span className="text-muted-foreground text-xs">{description}</span>
</div>
<div className="shrink-0">{children}</div>
</div>
)
}
export function AppLinksPage() {
const { addChange } = usePreviewStore()
const [rows, setRows] = useState<LinkRow[]>(INITIAL)
@@ -41,6 +100,9 @@ export function AppLinksPage() {
rowsRef.current = rows
}, [rows])
const [editing, setEditing] = useState<LinkRow | null>(null)
const [menuLabel, setMenuLabel] = useState('Приложения')
const [showCurrent, setShowCurrent] = useState(true)
const [newTab, setNewTab] = useState(false)
const setEnabled = useCallback(
(ids: AppId[], enabled: boolean) => {
@@ -58,52 +120,116 @@ export function AppLinksPage() {
[addChange],
)
const move = useCallback(
(id: AppId, dir: -1 | 1) => {
/** Переставляет строку: порядок строк = порядок в меню. */
const moveTo = useCallback(
(id: AppId, target: (index: number, length: number) => number) => {
const before = rowsRef.current
const i = before.findIndex((r) => r.id === id)
const j = i + dir
if (i < 0 || j < 0 || j >= before.length) return
const from = before.findIndex((r) => r.id === id)
const to = Math.max(0, Math.min(before.length - 1, target(from, before.length)))
if (from < 0 || from === to) return
const next = [...before]
;[next[i], next[j]] = [next[j]!, next[i]!]
const [item] = next.splice(from, 1)
next.splice(to, 0, item!)
setRows(next)
addChange({
kind: 'chg',
title: 'Порядок изменён',
lines: [` ${before[i]!.name}`, `− позиция ${i + 1}`, `+ позиция ${j + 1}`],
lines: [` ${item!.name}`, `− позиция ${from + 1}`, `+ позиция ${to + 1}`],
revert: () => setRows(before),
})
},
[addChange],
)
const resetOrder = useCallback(() => {
const before = rowsRef.current
if (before.every((r, i) => r.id === INITIAL[i]?.id)) return
setRows(INITIAL.map((i) => before.find((r) => r.id === i.id) ?? i))
addChange({
kind: 'chg',
title: 'Порядок сброшен',
lines: ['− пользовательский порядок', '+ порядок каталога'],
revert: () => setRows(before),
})
}, [addChange])
const columns = useMemo<GridColumn<LinkRow>[]>(
() => [
{ id: 'name', title: 'Приложение', size: 260, cell: (r) => <TwoLineCell dot={r.enabled ? 'ok' : 'mu'} title={r.name} sub={r.id} /> },
{ id: 'menu', title: 'В меню', size: 150, cell: (r) => <StatusBadge tone={r.enabled ? 'ok' : 'mu'}>{r.enabled ? 'В меню' : 'Скрыто'}</StatusBadge> },
{ id: 'icon', title: 'Иконка', size: 110, cell: (r) => <span className="text-sm">{r.icon}</span> },
{ id: 'url', title: 'URL', size: 260, cell: (r) => <code className="block truncate font-mono text-[13px]">{r.url}</code> },
{ id: 'mode', title: 'Вход', size: 90, cell: (r) => <StatusBadge tone={r.mode === 'OIDC' ? 'wn' : 'in'}>{r.mode}</StatusBadge> },
{
id: 'order',
title: 'Порядок',
size: 130,
title: '№',
size: 76,
cell: (r) => (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Button type="button" size="icon-sm" variant="ghost" aria-label={`Выше: ${r.name}`} onClick={() => move(r.id, -1)}>
<ArrowUpIcon aria-hidden />
</Button>
<Button type="button" size="icon-sm" variant="ghost" aria-label={`Ниже: ${r.name}`} onClick={() => move(r.id, 1)}>
<ArrowDownIcon aria-hidden />
</Button>
<span className="text-muted-foreground flex items-center gap-1 text-xs tabular-nums">
<GripVerticalIcon className="size-4" aria-hidden />
{rows.findIndex((x) => x.id === r.id) + 1}
</span>
),
},
{
id: 'name',
title: 'Приложение',
size: 280,
cell: (r) => {
const { Icon } = ICONS[r.icon]
return (
<div className="flex min-w-0 items-center gap-3">
<IconTile variant="elevated" size="sm" aria-hidden className="text-muted-foreground">
<Icon />
</IconTile>
<TwoLineCell title={r.name} sub={r.id} />
</div>
)
},
},
{
id: 'menu',
title: 'В меню',
size: 170,
cell: (r) => (
<div className="flex items-center gap-3" onClick={(e) => e.stopPropagation()}>
<Switch
size="sm"
checked={r.enabled}
onCheckedChange={(on) => setEnabled([r.id], on)}
aria-label={`${r.name}: показывать в меню`}
/>
<StatusBadge tone={r.enabled ? 'ok' : 'mu'}>{r.enabled ? 'В меню' : 'Скрыто'}</StatusBadge>
</div>
),
},
{ id: 'url', title: 'URL', size: 260, cell: (r) => <code className="block truncate font-mono text-[13px]">{r.url}</code> },
{ id: 'mode', title: 'Вход', size: 100, cell: (r) => <StatusBadge tone={r.mode === 'OIDC' ? 'wn' : 'in'}>{r.mode}</StatusBadge> },
{
id: 'act',
title: '',
size: 56,
cell: (r) => (
<div onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon-sm" aria-label={`Действия: ${r.name}`} />}>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuItem onClick={() => setEditing(r)}>
<PencilIcon aria-hidden />
Настроить
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => moveTo(r.id, () => 0)}>В начало</DropdownMenuItem>
<DropdownMenuItem onClick={() => moveTo(r.id, (i) => i - 1)}>Выше</DropdownMenuItem>
<DropdownMenuItem onClick={() => moveTo(r.id, (i) => i + 1)}>Ниже</DropdownMenuItem>
<DropdownMenuItem onClick={() => moveTo(r.id, (_i, n) => n - 1)}>В конец</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
],
[move],
[rows, setEnabled, moveTo],
)
const shown = rows.filter((r) => r.enabled).length
const shown = rows.filter((r) => r.enabled)
return (
<>
@@ -111,7 +237,14 @@ export function AppLinksPage() {
title="Ссылки приложений"
sub="Каталог приложений и переключатель в шапке · общий для всех сервисов семейства"
actions={
<Button variant="outline" onClick={() => toast('Добавление приложения недоступно в макете', { description: 'Каталог фиксирован контрактом: cfdm · vps · bgp · fw · dns · cdn · mm.' })}>
<Button
variant="outline"
onClick={() =>
toast('Добавление недоступно в макете', {
description: 'Каталог фиксирован контрактом: cfdm · vps · bgp · fw · dns · cdn · mm.',
})
}
>
<PlusIcon aria-hidden />
Добавить приложение
</Button>
@@ -121,7 +254,7 @@ export function AppLinksPage() {
aria-label="Показатели"
items={[
kpi('n', 'Приложений', String(rows.length), { icon: LayoutGridIcon, badge: 'в каталоге', sub: 'сервисов' }),
kpi('sw', 'В переключателе', String(shown), { icon: EyeIcon, tone: 'ok', badge: 'видны всем', sub: `из ${rows.length}` }),
kpi('sw', 'В переключателе', String(shown.length), { icon: EyeIcon, tone: 'ok', badge: 'видны всем', sub: `из ${rows.length}` }),
kpi('jwt', 'JWT SSO', String(rows.filter((r) => r.mode === 'JWT').length), { icon: KeyRoundIcon, tone: 'in', badge: 'fragment-токен', sub: 'без пароля' }),
kpi('oidc', 'OIDC', String(rows.filter((r) => r.mode === 'OIDC').length), { icon: FingerprintIcon, tone: 'in', badge: 'Technitium DNS', sub: 'внешний RP' }),
]}
@@ -131,7 +264,7 @@ export function AppLinksPage() {
<TabsTrigger value="list">Приложения · {rows.length}</TabsTrigger>
<TabsTrigger value="menu">Меню</TabsTrigger>
</TabsList>
<TabsContent value="list" className="flex min-w-0 flex-col gap-4">
<TabsContent value="list" className="min-w-0">
<PreviewSectionGrid
title="Ссылки сервисов"
description="Порядок строк = порядок в меню · изменения применяются после сохранения"
@@ -139,6 +272,7 @@ export function AppLinksPage() {
columns={columns}
getRowId={(r) => r.id}
selectable
pageSize={10}
searchText={(r) => `${r.name} ${r.id} ${r.url}`}
searchPlaceholder="Название или адрес"
views={[
@@ -146,28 +280,18 @@ export function AppLinksPage() {
{ label: 'В меню', match: (r) => r.enabled },
{ label: 'Скрытые', match: (r) => !r.enabled },
]}
toolbar={
<Button type="button" size="sm" variant="outline" onClick={resetOrder}>
<RotateCcwIcon aria-hidden />
Сбросить порядок
</Button>
}
bulkActions={(ids, clear) => (
<>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setEnabled(ids as AppId[], true)
clear()
}}
>
<Button type="button" size="sm" variant="outline" onClick={() => { setEnabled(ids as AppId[], true); clear() }}>
Показать в меню
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setEnabled(ids as AppId[], false)
clear()
}}
>
<Button type="button" size="sm" variant="outline" onClick={() => { setEnabled(ids as AppId[], false); clear() }}>
Скрыть
</Button>
</>
@@ -175,10 +299,46 @@ export function AppLinksPage() {
onRowClick={setEditing}
/>
</TabsContent>
<TabsContent value="menu">
<AppSwitcherAdminEditor
defaultValues={MOCK_SWITCHER}
onSave={() => toast.success('Переключатель сохранён (макет)')}
<TabsContent value="menu" className="flex min-w-0 flex-col gap-4">
<Frame spacing="sm" className="w-full">
<FrameHeader className="flex-row items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
<FrameTitle>Переключатель приложений</FrameTitle>
<FrameDescription>Как выглядит меню в шапке каждого приложения</FrameDescription>
</div>
<StatusBadge tone="ok">Опубликовано</StatusBadge>
</FrameHeader>
<FramePanel className="p-0">
<SettingRow title="Заголовок меню" description="Показывается над списком приложений">
<Input
value={menuLabel}
onChange={(e) => setMenuLabel(e.target.value)}
aria-label="Заголовок меню"
className="w-56 max-w-full"
/>
</SettingRow>
<SettingRow title="Показывать текущее приложение" description="Отмечать галочкой приложение, из которого открыто меню">
<Switch checked={showCurrent} onCheckedChange={setShowCurrent} aria-label="Показывать текущее приложение" />
</SettingRow>
<SettingRow title="Открывать в новой вкладке" description="Иначе переход в текущей вкладке">
<Switch checked={newTab} onCheckedChange={setNewTab} aria-label="Открывать в новой вкладке" />
</SettingRow>
</FramePanel>
</Frame>
<QuickActionGrid
title="Предпросмотр меню"
description={`«${menuLabel}» · так увидят пользователи с доступом ко всем приложениям`}
actions={shown.slice(0, 4).map((r) => {
const { Icon } = ICONS[r.icon]
return {
id: r.id,
title: r.name,
description: r.url.replace('https://', ''),
badgeLabel: r.mode,
badgeVariant: r.mode === 'OIDC' ? ('warning-light' as const) : ('info-light' as const),
icon: <Icon />,
}
})}
/>
</TabsContent>
</Tabs>
@@ -205,18 +365,50 @@ export function AppLinksPage() {
{editing ? (
<>
<Field>
<FieldLabel htmlFor="al-name">Название</FieldLabel>
<FieldLabel htmlFor="al-name">Название <span aria-hidden>*</span></FieldLabel>
<Input id="al-name" defaultValue={editing.name} />
</Field>
<Field>
<FieldLabel htmlFor="al-url">URL</FieldLabel>
<Input id="al-url" defaultValue={editing.url} />
<FieldLabel htmlFor="al-icon">Иконка</FieldLabel>
<Select
defaultValue={editing.icon}
items={Object.entries(ICONS).map(([value, v]) => ({ value, label: v.label }))}
>
<SelectTrigger id="al-icon" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{Object.entries(ICONS).map(([value, v]) => (
<SelectItem key={value} value={value}>
{v.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="al-url">URL <span aria-hidden>*</span></FieldLabel>
<Input id="al-url" defaultValue={editing.url} className="font-mono" />
<FieldDescription>https · домен из allowlist return_to</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="al-mode">Режим входа</FieldLabel>
<Input id="al-mode" value={editing.mode === 'JWT' ? 'JWT SSO (fragment)' : 'OIDC'} readOnly />
<FieldDescription>Приложения из каталога: {APPS.length}</FieldDescription>
<Select defaultValue={editing.mode} items={[...MODES]}>
<SelectTrigger id="al-mode" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{MODES.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field orientation="horizontal">
<Switch id="al-on" defaultChecked={editing.enabled} />