From a55d79c974b2e2498ea1cd465d13de8cc57d577f Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 18 Oct 2026 01:47:10 +0700 Subject: [PATCH] =?UTF-8?q?feat(design-preview):=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=20=C2=AB=D0=A1=D1=81=D1=8B=D0=BB?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B9=C2=BB=20=D0=BF=D0=BE=20=D0=BA=D0=B0=D0=BD=D0=B2?= =?UTF-8?q?=D0=B0=D1=81=D1=83=20=D0=BD=D0=B0=20=D0=B1=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0=D1=85=20ReUI=20PRO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Порядок с номером и ручкой, плитки иконок, переключатель «В меню» в строке, меню строки (в начало, выше, ниже, в конец), сброс порядка, вкладка «Меню» в стиле settings-10 с предпросмотром, форма приложения с выбором иконки и режима входа. Co-Authored-By: Claude Sonnet 5.5 --- .../design-preview/pages/app-links-page.tsx | 310 ++++++++++++++---- 1 file changed, 251 insertions(+), 59 deletions(-) diff --git a/apps/web/src/design-preview/pages/app-links-page.tsx b/apps/web/src/design-preview/pages/app-links-page.tsx index dd44585..38b6e1f 100644 --- a/apps/web/src/design-preview/pages/app-links-page.tsx +++ b/apps/web/src/design-preview/pages/app-links-page.tsx @@ -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 = { + 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 ( +
+
+ {title} + {description} +
+
{children}
+
+ ) +} + export function AppLinksPage() { const { addChange } = usePreviewStore() const [rows, setRows] = useState(INITIAL) @@ -41,6 +100,9 @@ export function AppLinksPage() { rowsRef.current = rows }, [rows]) const [editing, setEditing] = useState(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[]>( () => [ - { id: 'name', title: 'Приложение', size: 260, cell: (r) => }, - { id: 'menu', title: 'В меню', size: 150, cell: (r) => {r.enabled ? 'В меню' : 'Скрыто'} }, - { id: 'icon', title: 'Иконка', size: 110, cell: (r) => {r.icon} }, - { id: 'url', title: 'URL', size: 260, cell: (r) => {r.url} }, - { id: 'mode', title: 'Вход', size: 90, cell: (r) => {r.mode} }, { id: 'order', - title: 'Порядок', - size: 130, + title: '№', + size: 76, cell: (r) => ( -
e.stopPropagation()}> - - + + + {rows.findIndex((x) => x.id === r.id) + 1} + + ), + }, + { + id: 'name', + title: 'Приложение', + size: 280, + cell: (r) => { + const { Icon } = ICONS[r.icon] + return ( +
+ + + + +
+ ) + }, + }, + { + id: 'menu', + title: 'В меню', + size: 170, + cell: (r) => ( +
e.stopPropagation()}> + setEnabled([r.id], on)} + aria-label={`${r.name}: показывать в меню`} + /> + {r.enabled ? 'В меню' : 'Скрыто'} +
+ ), + }, + { id: 'url', title: 'URL', size: 260, cell: (r) => {r.url} }, + { id: 'mode', title: 'Вход', size: 100, cell: (r) => {r.mode} }, + { + id: 'act', + title: '', + size: 56, + cell: (r) => ( +
e.stopPropagation()}> + + }> + + + + setEditing(r)}> + + Настроить + + + moveTo(r.id, () => 0)}>В начало + moveTo(r.id, (i) => i - 1)}>Выше + moveTo(r.id, (i) => i + 1)}>Ниже + moveTo(r.id, (_i, n) => n - 1)}>В конец + +
), }, ], - [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={ - @@ -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() { Приложения · {rows.length} Меню - + 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={ + + } bulkActions={(ids, clear) => ( <> - - @@ -175,10 +299,46 @@ export function AppLinksPage() { onRowClick={setEditing} /> - - toast.success('Переключатель сохранён (макет)')} + + + +
+ Переключатель приложений + Как выглядит меню в шапке каждого приложения +
+ Опубликовано +
+ + + setMenuLabel(e.target.value)} + aria-label="Заголовок меню" + className="w-56 max-w-full" + /> + + + + + + + + + + { + 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: , + } + })} />
@@ -205,18 +365,50 @@ export function AppLinksPage() { {editing ? ( <> - Название + Название * - URL - + Иконка + + + + URL * + https · домен из allowlist return_to Режим входа - - Приложения из каталога: {APPS.length} +