Files
auth-portal/apps/web/src/design-preview/pages/app-links-page.tsx
T

330 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
</>
)
}