feat(design-preview): страница «Ссылки приложений» по канвасу на блоках ReUI PRO
Порядок с номером и ручкой, плитки иконок, переключатель «В меню» в строке, меню строки (в начало, выше, ниже, в конец), сброс порядка, вкладка «Меню» в стиле settings-10 с предпросмотром, форма приложения с выбором иконки и режима входа. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
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} />
|
||||
|
||||
Reference in new issue
Block a user