330 lines
14 KiB
TypeScript
330 lines
14 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
import { ChartColumnIcon, CloudIcon, EyeIcon, KeyRoundIcon, LayoutDashboardIcon, LayoutGridIcon, LinkIcon, NetworkIcon, ServerIcon, type LucideIcon } from 'lucide-react'
|
||
import { toast } from 'sonner'
|
||
import type { AppId, AppSwitcherIconName } from '@authportal/shared'
|
||
import { Button } from '@authportal/ui/components/button'
|
||
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 } from '@authportal/ui/components/tabs'
|
||
import { KpiStrip } from '@/components/portal/kpi-strip'
|
||
import { PageHead, PortalSheet, StatusBadge, PageTab, PageTabsList, FormFrame, SettingRow } from '@/components/portal/bits'
|
||
import { AppCards } from '@/components/portal/canvas-blocks'
|
||
import { kpi } from '@/components/portal/kpi'
|
||
import { MOCK_SWITCHER } from '../mock'
|
||
import { usePreviewStore } from '../preview-store'
|
||
import { SectionGrid, TwoLineCell, type GridColumn } from '@/components/portal/section-grid'
|
||
|
||
type LinkRow = {
|
||
id: AppId
|
||
name: string
|
||
url: 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 BOARD_ICON: Record<string, string> = { cfdm: 'cloud', vps: 'server', bgp: 'globe', fw: 'shield', dns: 'globe', cdn: 'cloud', mm: 'layout' }
|
||
|
||
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,
|
||
url: a.url,
|
||
icon: a.icon,
|
||
mode: (a.authMode ?? 'jwt').toUpperCase() as 'JWT' | 'OIDC',
|
||
enabled: a.id !== 'mm'
|
||
}))
|
||
|
||
export function AppLinksPage() {
|
||
const { addChange, marksFor } = usePreviewStore()
|
||
const marks = marksFor('app-links')
|
||
const [rows, setRows] = useState<LinkRow[]>(INITIAL)
|
||
const rowsRef = useRef(rows)
|
||
useEffect(() => {
|
||
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) => {
|
||
const before = rowsRef.current
|
||
const names = before.filter((r) => ids.includes(r.id) && r.enabled !== enabled).map((r) => r.name)
|
||
if (names.length === 0) return
|
||
setRows((prev) => prev.map((r) => (ids.includes(r.id) ? { ...r, enabled } : r)))
|
||
addChange({
|
||
kind: 'chg',
|
||
title: enabled ? 'Показать в меню' : 'Скрыть из меню',
|
||
lines: names.map((n) => `${enabled ? '+' : '−'} ${n}`),
|
||
resource: 'app-links',
|
||
marks: Object.fromEntries(ids.map((i) => [i, 'dirty' as const])),
|
||
revert: () => setRows(before)
|
||
})
|
||
},
|
||
[addChange],
|
||
)
|
||
|
||
/** Переставляет строку: порядок строк = порядок в меню. */
|
||
const moveTo = useCallback(
|
||
(id: AppId, target: (index: number, length: number) => number) => {
|
||
const before = rowsRef.current
|
||
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]
|
||
const [item] = next.splice(from, 1)
|
||
next.splice(to, 0, item!)
|
||
setRows(next)
|
||
addChange({
|
||
kind: 'chg',
|
||
title: 'Порядок изменён',
|
||
lines: [` ${item!.name}`, `− позиция ${from + 1}`, `+ позиция ${to + 1}`],
|
||
resource: 'app-links',
|
||
marks: { [item!.id]: 'dirty' },
|
||
revert: () => setRows(before)
|
||
})
|
||
},
|
||
[addChange],
|
||
)
|
||
|
||
/** Перетаскивание: строка встаёт на место той, на которую её бросили. */
|
||
const dropOn = useCallback(
|
||
(activeId: string, overId: string) => {
|
||
const to = rowsRef.current.findIndex((r) => r.id === overId)
|
||
if (to >= 0) moveTo(activeId as AppId, () => to)
|
||
},
|
||
[moveTo],
|
||
)
|
||
|
||
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: ['− пользовательский порядок', '+ порядок каталога'],
|
||
resource: 'app-links',
|
||
marks: Object.fromEntries(before.map((r) => [r.id, 'dirty' as const])),
|
||
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: 'status', title: 'Статус', size: 130, cell: (r) => <StatusBadge tone={r.enabled ? 'ok' : 'mu'}>{r.enabled ? 'В меню' : 'Скрыто'}</StatusBadge> },
|
||
{ id: 'icon', title: 'Иконка', size: 120, cell: (r) => <span className="font-mono text-[12.5px]">{BOARD_ICON[r.id] ?? r.icon}</span> },
|
||
{ id: 'url', title: 'URL', size: 260, cell: (r) => <span className="block truncate font-mono text-[12.5px]">{r.url}</span> },
|
||
{ id: 'mode', title: 'Вход', size: 90, cell: (r) => <span className="font-mono text-[12.5px]">{r.mode}</span> },
|
||
],
|
||
[],
|
||
)
|
||
|
||
const shown = rows.filter((r) => r.enabled)
|
||
|
||
return (
|
||
<>
|
||
<PageHead
|
||
title="Ссылки приложений"
|
||
sub="Каталог приложений и переключатель в шапке · общий для всех сервисов семейства"
|
||
actions={
|
||
<Button
|
||
variant="outline"
|
||
onClick={() =>
|
||
toast('Добавление недоступно в макете', {
|
||
description: 'Каталог фиксирован контрактом: cfdm · vps · bgp · fw · dns · cdn · mm.'
|
||
})
|
||
}
|
||
>
|
||
Добавить приложение
|
||
</Button>
|
||
}
|
||
/>
|
||
<KpiStrip
|
||
aria-label="Показатели"
|
||
items={[
|
||
kpi('n', 'Приложений', String(rows.length), { icon: LayoutGridIcon, badge: 'в каталоге' }),
|
||
kpi('sw', 'В переключателе', String(shown.length), { icon: EyeIcon, tone: 'ok', badge: 'видны всем' }),
|
||
kpi('jwt', 'JWT SSO', String(rows.filter((r) => r.mode === 'JWT').length), { icon: KeyRoundIcon, badge: 'fragment-токен' }),
|
||
kpi('oidc', 'OIDC', String(rows.filter((r) => r.mode === 'OIDC').length), { icon: LinkIcon, badge: 'Technitium DNS' }),
|
||
]}
|
||
/>
|
||
<Tabs defaultValue="list" className="min-w-0">
|
||
<PageTabsList>
|
||
<PageTab value="list" label="Приложения" count={rows.length} />
|
||
<PageTab value="menu" label="Меню" />
|
||
</PageTabsList>
|
||
<TabsContent value="list" className="min-w-0">
|
||
<SectionGrid
|
||
title="Ссылки сервисов"
|
||
description="Порядок строк = порядок в меню · переключатель управляет видимостью · изменения применяются после сохранения"
|
||
rows={rows}
|
||
columns={columns}
|
||
getRowId={(r) => r.id}
|
||
pageSize={10}
|
||
searchText={(r) => `${r.name} ${r.id} ${r.url}`}
|
||
searchPlaceholder="Название или адрес"
|
||
views={[
|
||
{ label: 'Все', match: () => true },
|
||
{ label: 'В меню', match: (r) => r.enabled },
|
||
{ label: 'Скрытые', match: (r) => !r.enabled },
|
||
]}
|
||
tools={
|
||
<Button type="button" variant="outline" onClick={resetOrder}>
|
||
Сбросить порядок
|
||
</Button>
|
||
}
|
||
bulk={{
|
||
onLabel: 'Показать в меню',
|
||
offLabel: 'Скрыть',
|
||
onRun: (ids) => setEnabled(ids as AppId[], true),
|
||
offRun: (ids) => setEnabled(ids as AppId[], false)
|
||
}}
|
||
rowSwitch={{ value: (r) => r.enabled, onChange: (r, on) => setEnabled([r.id], on) }}
|
||
rowMenu={(r) => ({
|
||
onOpen: () => setEditing(r),
|
||
toggleLabel: r.enabled ? 'Скрыть из меню' : 'Показать в меню',
|
||
onToggle: () => setEnabled([r.id], !r.enabled)
|
||
})}
|
||
onReorder={dropOn}
|
||
rowStatus={(r) => marks[r.id]}
|
||
onRowClick={setEditing}
|
||
/>
|
||
</TabsContent>
|
||
<TabsContent value="menu" className="flex min-w-0 flex-col gap-4">
|
||
<FormFrame title="Переключатель приложений" description="Как выглядит меню в шапке каждого приложения" badge="Опубликовано">
|
||
<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>
|
||
</FormFrame>
|
||
<AppCards
|
||
title="Предпросмотр меню"
|
||
description={`«${menuLabel}» · так увидят пользователи с доступом ко всем приложениям`}
|
||
items={shown.slice(0, 4).map((r) => {
|
||
const { Icon } = ICONS[r.icon]
|
||
return {
|
||
id: r.id,
|
||
title: r.name,
|
||
desc: r.url.replace('https://', ''),
|
||
badge: r.mode,
|
||
badgeVariant: r.mode === 'OIDC' ? ('warning-light' as const) : ('info-light' as const),
|
||
meta: r.id,
|
||
icon: <Icon />
|
||
}
|
||
})}
|
||
/>
|
||
</TabsContent>
|
||
</Tabs>
|
||
|
||
<PortalSheet
|
||
open={editing !== null}
|
||
onOpenChange={(o) => !o && setEditing(null)}
|
||
title="Приложение"
|
||
description="Название, иконка и адрес в переключателе"
|
||
footer={
|
||
<>
|
||
<Button variant="outline" onClick={() => setEditing(null)}>Отмена</Button>
|
||
<Button
|
||
onClick={() => {
|
||
setEditing(null)
|
||
toast.success('Приложение сохранено (макет)')
|
||
}}
|
||
>
|
||
Сохранить
|
||
</Button>
|
||
</>
|
||
}
|
||
>
|
||
{editing ? (
|
||
<>
|
||
<Field>
|
||
<FieldLabel htmlFor="al-name">Название <span aria-hidden>*</span></FieldLabel>
|
||
<Input id="al-name" defaultValue={editing.name} />
|
||
</Field>
|
||
<Field>
|
||
<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>
|
||
<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} />
|
||
<div className="flex flex-col">
|
||
<FieldLabel htmlFor="al-on">Включено в переключатель</FieldLabel>
|
||
<FieldDescription>Скрытое приложение остаётся доступным по прямой ссылке</FieldDescription>
|
||
</div>
|
||
</Field>
|
||
</>
|
||
) : null}
|
||
</PortalSheet>
|
||
</>
|
||
)
|
||
}
|