feat(design-preview): страница «Ссылки приложений» по канвасу на блоках ReUI PRO
Порядок с номером и ручкой, плитки иконок, переключатель «В меню» в строке, меню строки (в начало, выше, ниже, в конец), сброс порядка, вкладка «Меню» в стиле settings-10 с предпросмотром, форма приложения с выбором иконки и режима входа. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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 { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||||
import { ArrowDownIcon, ArrowUpIcon, PlusIcon, LayoutGridIcon, EyeIcon, KeyRoundIcon, FingerprintIcon } from 'lucide-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 { toast } from 'sonner'
|
||||||
import { APPS, type AppId } from '@authportal/shared'
|
import type { AppId, AppSwitcherIconName } from '@authportal/shared'
|
||||||
import { AppSwitcherAdminEditor } from '@/components/reui-kit/app-switcher-admin-editor'
|
import { QuickActionGrid } from '@/components/reui-kit/quick-action-grid'
|
||||||
import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-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 { 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 { Field, FieldDescription, FieldLabel } from '@authportal/ui/components/field'
|
||||||
import { Input } from '@authportal/ui/components/input'
|
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 { Switch } from '@authportal/ui/components/switch'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
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 { PageHead, PreviewSheet, StatusBadge } from '../bits'
|
||||||
import { kpi } from '../kpi'
|
import { kpi } from '../kpi'
|
||||||
import { MOCK_SWITCHER } from '../mock'
|
import { MOCK_SWITCHER } from '../mock'
|
||||||
@@ -19,11 +52,24 @@ type LinkRow = {
|
|||||||
id: AppId
|
id: AppId
|
||||||
name: string
|
name: string
|
||||||
url: string
|
url: string
|
||||||
icon: string
|
icon: AppSwitcherIconName
|
||||||
mode: 'JWT' | 'OIDC'
|
mode: 'JWT' | 'OIDC'
|
||||||
enabled: boolean
|
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) => ({
|
const INITIAL: LinkRow[] = MOCK_SWITCHER.apps.map((a) => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
name: a.name,
|
name: a.name,
|
||||||
@@ -33,6 +79,19 @@ const INITIAL: LinkRow[] = MOCK_SWITCHER.apps.map((a) => ({
|
|||||||
enabled: a.id !== 'mm',
|
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() {
|
export function AppLinksPage() {
|
||||||
const { addChange } = usePreviewStore()
|
const { addChange } = usePreviewStore()
|
||||||
const [rows, setRows] = useState<LinkRow[]>(INITIAL)
|
const [rows, setRows] = useState<LinkRow[]>(INITIAL)
|
||||||
@@ -41,6 +100,9 @@ export function AppLinksPage() {
|
|||||||
rowsRef.current = rows
|
rowsRef.current = rows
|
||||||
}, [rows])
|
}, [rows])
|
||||||
const [editing, setEditing] = useState<LinkRow | null>(null)
|
const [editing, setEditing] = useState<LinkRow | null>(null)
|
||||||
|
const [menuLabel, setMenuLabel] = useState('Приложения')
|
||||||
|
const [showCurrent, setShowCurrent] = useState(true)
|
||||||
|
const [newTab, setNewTab] = useState(false)
|
||||||
|
|
||||||
const setEnabled = useCallback(
|
const setEnabled = useCallback(
|
||||||
(ids: AppId[], enabled: boolean) => {
|
(ids: AppId[], enabled: boolean) => {
|
||||||
@@ -58,52 +120,116 @@ export function AppLinksPage() {
|
|||||||
[addChange],
|
[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 before = rowsRef.current
|
||||||
const i = before.findIndex((r) => r.id === id)
|
const from = before.findIndex((r) => r.id === id)
|
||||||
const j = i + dir
|
const to = Math.max(0, Math.min(before.length - 1, target(from, before.length)))
|
||||||
if (i < 0 || j < 0 || j >= before.length) return
|
if (from < 0 || from === to) return
|
||||||
const next = [...before]
|
const next = [...before]
|
||||||
;[next[i], next[j]] = [next[j]!, next[i]!]
|
const [item] = next.splice(from, 1)
|
||||||
|
next.splice(to, 0, item!)
|
||||||
setRows(next)
|
setRows(next)
|
||||||
addChange({
|
addChange({
|
||||||
kind: 'chg',
|
kind: 'chg',
|
||||||
title: 'Порядок изменён',
|
title: 'Порядок изменён',
|
||||||
lines: [` ${before[i]!.name}`, `− позиция ${i + 1}`, `+ позиция ${j + 1}`],
|
lines: [` ${item!.name}`, `− позиция ${from + 1}`, `+ позиция ${to + 1}`],
|
||||||
revert: () => setRows(before),
|
revert: () => setRows(before),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[addChange],
|
[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>[]>(
|
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',
|
id: 'order',
|
||||||
title: 'Порядок',
|
title: '№',
|
||||||
size: 130,
|
size: 76,
|
||||||
cell: (r) => (
|
cell: (r) => (
|
||||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
<span className="text-muted-foreground flex items-center gap-1 text-xs tabular-nums">
|
||||||
<Button type="button" size="icon-sm" variant="ghost" aria-label={`Выше: ${r.name}`} onClick={() => move(r.id, -1)}>
|
<GripVerticalIcon className="size-4" aria-hidden />
|
||||||
<ArrowUpIcon aria-hidden />
|
{rows.findIndex((x) => x.id === r.id) + 1}
|
||||||
</Button>
|
</span>
|
||||||
<Button type="button" size="icon-sm" variant="ghost" aria-label={`Ниже: ${r.name}`} onClick={() => move(r.id, 1)}>
|
),
|
||||||
<ArrowDownIcon aria-hidden />
|
},
|
||||||
</Button>
|
{
|
||||||
|
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>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[move],
|
[rows, setEnabled, moveTo],
|
||||||
)
|
)
|
||||||
|
|
||||||
const shown = rows.filter((r) => r.enabled).length
|
const shown = rows.filter((r) => r.enabled)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -111,7 +237,14 @@ export function AppLinksPage() {
|
|||||||
title="Ссылки приложений"
|
title="Ссылки приложений"
|
||||||
sub="Каталог приложений и переключатель в шапке · общий для всех сервисов семейства"
|
sub="Каталог приложений и переключатель в шапке · общий для всех сервисов семейства"
|
||||||
actions={
|
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 />
|
<PlusIcon aria-hidden />
|
||||||
Добавить приложение
|
Добавить приложение
|
||||||
</Button>
|
</Button>
|
||||||
@@ -121,7 +254,7 @@ export function AppLinksPage() {
|
|||||||
aria-label="Показатели"
|
aria-label="Показатели"
|
||||||
items={[
|
items={[
|
||||||
kpi('n', 'Приложений', String(rows.length), { icon: LayoutGridIcon, badge: 'в каталоге', sub: 'сервисов' }),
|
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('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' }),
|
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="list">Приложения · {rows.length}</TabsTrigger>
|
||||||
<TabsTrigger value="menu">Меню</TabsTrigger>
|
<TabsTrigger value="menu">Меню</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="list" className="flex min-w-0 flex-col gap-4">
|
<TabsContent value="list" className="min-w-0">
|
||||||
<PreviewSectionGrid
|
<PreviewSectionGrid
|
||||||
title="Ссылки сервисов"
|
title="Ссылки сервисов"
|
||||||
description="Порядок строк = порядок в меню · изменения применяются после сохранения"
|
description="Порядок строк = порядок в меню · изменения применяются после сохранения"
|
||||||
@@ -139,6 +272,7 @@ export function AppLinksPage() {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
getRowId={(r) => r.id}
|
getRowId={(r) => r.id}
|
||||||
selectable
|
selectable
|
||||||
|
pageSize={10}
|
||||||
searchText={(r) => `${r.name} ${r.id} ${r.url}`}
|
searchText={(r) => `${r.name} ${r.id} ${r.url}`}
|
||||||
searchPlaceholder="Название или адрес"
|
searchPlaceholder="Название или адрес"
|
||||||
views={[
|
views={[
|
||||||
@@ -146,28 +280,18 @@ export function AppLinksPage() {
|
|||||||
{ label: 'В меню', match: (r) => r.enabled },
|
{ label: 'В меню', match: (r) => r.enabled },
|
||||||
{ 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) => (
|
bulkActions={(ids, clear) => (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button type="button" size="sm" variant="outline" onClick={() => { setEnabled(ids as AppId[], true); clear() }}>
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setEnabled(ids as AppId[], true)
|
|
||||||
clear()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Показать в меню
|
Показать в меню
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button type="button" size="sm" variant="outline" onClick={() => { setEnabled(ids as AppId[], false); clear() }}>
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setEnabled(ids as AppId[], false)
|
|
||||||
clear()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Скрыть
|
Скрыть
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -175,10 +299,46 @@ export function AppLinksPage() {
|
|||||||
onRowClick={setEditing}
|
onRowClick={setEditing}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="menu">
|
<TabsContent value="menu" className="flex min-w-0 flex-col gap-4">
|
||||||
<AppSwitcherAdminEditor
|
<Frame spacing="sm" className="w-full">
|
||||||
defaultValues={MOCK_SWITCHER}
|
<FrameHeader className="flex-row items-center justify-between gap-3">
|
||||||
onSave={() => toast.success('Переключатель сохранён (макет)')}
|
<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>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -205,18 +365,50 @@ export function AppLinksPage() {
|
|||||||
{editing ? (
|
{editing ? (
|
||||||
<>
|
<>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="al-name">Название</FieldLabel>
|
<FieldLabel htmlFor="al-name">Название <span aria-hidden>*</span></FieldLabel>
|
||||||
<Input id="al-name" defaultValue={editing.name} />
|
<Input id="al-name" defaultValue={editing.name} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="al-url">URL</FieldLabel>
|
<FieldLabel htmlFor="al-icon">Иконка</FieldLabel>
|
||||||
<Input id="al-url" defaultValue={editing.url} />
|
<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>
|
<FieldDescription>https · домен из allowlist return_to</FieldDescription>
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="al-mode">Режим входа</FieldLabel>
|
<FieldLabel htmlFor="al-mode">Режим входа</FieldLabel>
|
||||||
<Input id="al-mode" value={editing.mode === 'JWT' ? 'JWT SSO (fragment)' : 'OIDC'} readOnly />
|
<Select defaultValue={editing.mode} items={[...MODES]}>
|
||||||
<FieldDescription>Приложения из каталога: {APPS.length}</FieldDescription>
|
<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>
|
||||||
<Field orientation="horizontal">
|
<Field orientation="horizontal">
|
||||||
<Switch id="al-on" defaultChecked={editing.enabled} />
|
<Switch id="al-on" defaultChecked={editing.enabled} />
|
||||||
|
|||||||
Reference in new issue
Block a user