482 lines
15 KiB
TypeScript
482 lines
15 KiB
TypeScript
import { useState, type ReactNode } from 'react'
|
|
import { CircleAlertIcon, InfoIcon, TriangleAlertIcon, XIcon } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
|
import { Badge } from '@/components/reui/badge'
|
|
import {
|
|
Frame,
|
|
FrameDescription,
|
|
FrameHeader,
|
|
FramePanel,
|
|
FrameTitle,
|
|
} from '@/components/reui/frame'
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@authportal/ui/components/alert-dialog'
|
|
import { Button } from '@authportal/ui/components/button'
|
|
import { cn } from '@authportal/ui/lib/utils'
|
|
import { Input } from '@authportal/ui/components/input'
|
|
import { Label } from '@authportal/ui/components/label'
|
|
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
|
|
import {
|
|
Sheet,
|
|
SheetClose,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetFooter,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from '@authportal/ui/components/sheet'
|
|
import type { Tone } from './mock'
|
|
import { TONE_BADGE } from './tone'
|
|
|
|
const TONE_GLYPH: Record<Tone, string> = { ok: '●', wn: '▲', er: '✕', mu: '○', in: '' }
|
|
|
|
/** Плашка статуса: слово + глиф (● ▲ ✕ ○), чтобы цвет не был единственным признаком. */
|
|
export function StatusBadge({ tone, children }: { tone: Tone; children: ReactNode }) {
|
|
const glyph = TONE_GLYPH[tone]
|
|
return (
|
|
<Badge variant={TONE_BADGE[tone]} size="sm" className="whitespace-nowrap">
|
|
{glyph ? <span aria-hidden className="text-[0.55rem]">{glyph}</span> : null}
|
|
{children}
|
|
</Badge>
|
|
)
|
|
}
|
|
|
|
/** Заголовок страницы: название, описание, метка свежести и действия. */
|
|
export function PageHead({
|
|
title,
|
|
sub,
|
|
actions,
|
|
}: {
|
|
title: string
|
|
sub: string
|
|
actions?: ReactNode
|
|
}) {
|
|
return (
|
|
<header className="flex flex-wrap items-end justify-between gap-3">
|
|
<div className="flex min-w-0 flex-col gap-0.5">
|
|
<h1 className="text-xl font-semibold tracking-[-0.01em]">{title}</h1>
|
|
<p className="text-muted-foreground text-sm">{sub}</p>
|
|
</div>
|
|
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
|
|
</header>
|
|
)
|
|
}
|
|
|
|
const ALERT_ICON = {
|
|
er: <CircleAlertIcon aria-hidden />,
|
|
wn: <TriangleAlertIcon aria-hidden />,
|
|
in: <InfoIcon aria-hidden />,
|
|
} as const
|
|
|
|
const ALERT_VARIANT = {
|
|
er: 'destructive',
|
|
wn: 'warning',
|
|
in: 'info',
|
|
} as const
|
|
|
|
export function SignalAlert({
|
|
tone,
|
|
title,
|
|
children,
|
|
action,
|
|
}: {
|
|
tone: 'er' | 'wn' | 'in'
|
|
title: string
|
|
children: ReactNode
|
|
action?: ReactNode
|
|
}) {
|
|
return (
|
|
<Alert variant={ALERT_VARIANT[tone]}>
|
|
{ALERT_ICON[tone]}
|
|
<AlertTitle>{title}</AlertTitle>
|
|
<AlertDescription>{children}</AlertDescription>
|
|
{action ? <AlertAction>{action}</AlertAction> : null}
|
|
</Alert>
|
|
)
|
|
}
|
|
|
|
/** Боковое окно (как CreateUserSheet): заголовок, прокручиваемое тело, подвал. */
|
|
export function PreviewSheet({
|
|
open,
|
|
onOpenChange,
|
|
title,
|
|
description,
|
|
children,
|
|
footer,
|
|
size = 'md',
|
|
dirty,
|
|
}: {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
title: string
|
|
description: string
|
|
children: ReactNode
|
|
footer?: ReactNode
|
|
/** Ширина панели: md — форма, lg — редактор с двумя колонками. */
|
|
size?: 'md' | 'lg'
|
|
/** Явный признак изменений (иначе ловим ввод в полях и переключатели). */
|
|
dirty?: boolean
|
|
}) {
|
|
const [touchedState, setTouched] = useState(false)
|
|
const touched = dirty ?? touchedState
|
|
const [guard, setGuard] = useState(false)
|
|
|
|
function requestChange(next: boolean) {
|
|
if (!next && touched) {
|
|
setGuard(true)
|
|
return
|
|
}
|
|
if (!next) setTouched(false)
|
|
onOpenChange(next)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Sheet open={open} onOpenChange={requestChange}>
|
|
<SheetContent
|
|
side="right"
|
|
showCloseButton={false}
|
|
className={cn(
|
|
'inset-y-0 right-0 left-auto flex h-svh max-w-none flex-col gap-0 overflow-hidden rounded-none p-0 outline-none sm:max-w-none',
|
|
'data-[side=right]:sm:max-w-none',
|
|
size === 'lg'
|
|
? 'data-[side=right]:w-[min(45rem,100vw)]'
|
|
: 'data-[side=right]:w-[min(30rem,100vw)]',
|
|
)}
|
|
>
|
|
<SheetHeader className="shrink-0 gap-0 p-0">
|
|
<div className="flex min-h-12 items-center justify-between gap-2 border-b px-4">
|
|
<SheetTitle className="min-w-0 truncate text-base font-semibold">{title}</SheetTitle>
|
|
<SheetClose
|
|
render={
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label="Закрыть"
|
|
className="text-muted-foreground hover:text-foreground"
|
|
/>
|
|
}
|
|
>
|
|
<XIcon aria-hidden="true" />
|
|
</SheetClose>
|
|
</div>
|
|
<SheetDescription className="text-muted-foreground px-4 pt-3 text-sm">
|
|
{description}
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
<div
|
|
className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4"
|
|
onInput={() => setTouched(true)}
|
|
onClick={(e) => {
|
|
if ((e.target as HTMLElement).closest('[role=switch]')) setTouched(true)
|
|
}}
|
|
>
|
|
{children}
|
|
</div>
|
|
{footer ? (
|
|
<SheetFooter
|
|
className="flex-row flex-wrap justify-end gap-2 border-t px-4 py-3"
|
|
onClickCapture={(e) => {
|
|
// «Закрыть» и «Отмена» при изменённой форме тоже спрашивают подтверждение.
|
|
const label = (e.target as HTMLElement).closest('button')?.textContent?.trim()
|
|
if (touched && (label === 'Закрыть' || label === 'Отмена')) {
|
|
e.stopPropagation()
|
|
setGuard(true)
|
|
}
|
|
}}
|
|
>{footer}</SheetFooter>
|
|
) : null}
|
|
</SheetContent>
|
|
</Sheet>
|
|
<AlertDialog open={guard} onOpenChange={setGuard}>
|
|
<AlertDialogContent size="sm">
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Закрыть без сохранения?</AlertDialogTitle>
|
|
<AlertDialogDescription>Изменения в форме будут потеряны.</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Остаться</AlertDialogCancel>
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
onClick={() => {
|
|
setGuard(false)
|
|
setTouched(false)
|
|
onOpenChange(false)
|
|
}}
|
|
>
|
|
Закрыть без сохранения
|
|
</Button>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</>
|
|
)
|
|
}
|
|
|
|
/** Подтверждение опасного действия вводом слова (email, «сбросить», «очистить»…). */
|
|
export function TypedConfirm({
|
|
open,
|
|
onOpenChange,
|
|
title,
|
|
description,
|
|
expected,
|
|
confirmLabel,
|
|
onConfirm,
|
|
}: {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
title: string
|
|
description: ReactNode
|
|
expected: string
|
|
confirmLabel: string
|
|
onConfirm: () => void
|
|
}) {
|
|
const [value, setValue] = useState('')
|
|
const matches = value.trim().toLowerCase() === expected.toLowerCase()
|
|
|
|
return (
|
|
<AlertDialog
|
|
open={open}
|
|
onOpenChange={(next) => {
|
|
if (!next) setValue('')
|
|
onOpenChange(next)
|
|
}}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
|
<AlertDialogDescription>{description}</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="typed-confirm">
|
|
Введите <span className="font-mono font-semibold">{expected}</span> для подтверждения
|
|
</Label>
|
|
<Input
|
|
id="typed-confirm"
|
|
value={value}
|
|
autoComplete="off"
|
|
onChange={(e) => setValue(e.target.value)}
|
|
aria-invalid={value.length > 0 && !matches}
|
|
/>
|
|
</div>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
disabled={!matches}
|
|
onClick={() => {
|
|
setValue('')
|
|
onConfirm()
|
|
}}
|
|
>
|
|
{confirmLabel}
|
|
</Button>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
)
|
|
}
|
|
|
|
export function KvFrame({
|
|
title,
|
|
description,
|
|
items,
|
|
}: {
|
|
title: string
|
|
description: string
|
|
items: Array<{ label: string; value: ReactNode }>
|
|
}) {
|
|
return (
|
|
<Frame className="[--frame-panel-header-py-adjust:4px] w-full">
|
|
<FrameHeader>
|
|
<FrameTitle>{title}</FrameTitle>
|
|
<FrameDescription>{description}</FrameDescription>
|
|
</FrameHeader>
|
|
<FramePanel className="p-0">
|
|
<dl className="grid divide-y @container sm:grid-cols-2 sm:divide-y-0">
|
|
{items.map((item) => (
|
|
<div
|
|
key={item.label}
|
|
className="flex flex-col gap-0.5 border-b px-4 py-3 last:border-b-0 sm:odd:border-r"
|
|
>
|
|
<dt className="text-muted-foreground text-xs">{item.label}</dt>
|
|
<dd className="text-sm font-medium break-words">{item.value}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</FramePanel>
|
|
</Frame>
|
|
)
|
|
}
|
|
|
|
/** Список «значение + подпись + копировать» (параметры issuer). */
|
|
export function CopyList({
|
|
title,
|
|
description,
|
|
items,
|
|
}: {
|
|
title: string
|
|
description: string
|
|
items: Array<{ label: string; value: string }>
|
|
}) {
|
|
return (
|
|
<Frame className="[--frame-panel-header-py-adjust:4px] w-full">
|
|
<FrameHeader>
|
|
<FrameTitle>{title}</FrameTitle>
|
|
<FrameDescription>{description}</FrameDescription>
|
|
</FrameHeader>
|
|
<FramePanel className="p-0">
|
|
<ul className="flex flex-col">
|
|
{items.map((item) => (
|
|
<li
|
|
key={item.label}
|
|
className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b px-4 py-2.5 last:border-b-0"
|
|
>
|
|
<code className="min-w-0 flex-1 basis-72 font-mono text-[13px] break-all">{item.value}</code>
|
|
<span className="text-muted-foreground min-w-0 flex-1 basis-48 text-[13px]">{item.label}</span>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
aria-label={`Скопировать: ${item.label}`}
|
|
onClick={() => {
|
|
void navigator.clipboard?.writeText(item.value).catch(() => undefined)
|
|
toast.success(`${item.label} скопирован`)
|
|
}}
|
|
>
|
|
Копировать
|
|
</Button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</FramePanel>
|
|
</Frame>
|
|
)
|
|
}
|
|
|
|
/** Компактный список «ключ — значение» для боковых окон. */
|
|
export function KvList({ items }: { items: Array<[string, string]> }) {
|
|
return (
|
|
<dl className="flex flex-col divide-y rounded-lg border">
|
|
{items.map(([k, v]) => (
|
|
<div key={k} className="flex flex-col gap-0.5 px-3 py-2">
|
|
<dt className="text-muted-foreground text-xs">{k}</dt>
|
|
<dd className="text-sm break-words">{v}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
)
|
|
}
|
|
|
|
export function CodeBlock({ label, children }: { label: string; children: string }) {
|
|
return (
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-muted-foreground text-xs">{label}</span>
|
|
<pre className="bg-muted/40 overflow-x-auto rounded-md border p-3 font-mono text-xs leading-relaxed">{children}</pre>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Сегментированный переключатель на Tabs (вид таблицы, период). */
|
|
export function Segmented({
|
|
label,
|
|
options,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
label: string
|
|
options: string[]
|
|
value: number
|
|
onChange: (index: number) => void
|
|
}) {
|
|
return (
|
|
<Tabs value={String(value)} onValueChange={(v) => onChange(Number(v))} aria-label={label} className="max-w-full">
|
|
<TabsList className="max-w-full justify-start overflow-x-auto">
|
|
{options.map((o, i) => (
|
|
<TabsTrigger key={o} value={String(i)}>
|
|
{o}
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
</Tabs>
|
|
)
|
|
}
|
|
|
|
/** Плашка строки с накопленной правкой: «Не сохранено» или «Будет удалено». */
|
|
export function PendingChip({ mark }: { mark: 'new' | 'dirty' | 'deleted' }) {
|
|
return (
|
|
<Badge variant={mark === 'deleted' ? 'destructive-light' : 'warning-light'} size="sm" className="whitespace-nowrap">
|
|
{mark === 'deleted' ? 'Будет удалено' : 'Не сохранено'}
|
|
</Badge>
|
|
)
|
|
}
|
|
|
|
/** Вкладки страницы как на досках: линия-подчёркивание, число — мелким индексом. */
|
|
export function PageTabsList({ children }: { children: ReactNode }) {
|
|
return (
|
|
<TabsList variant="line" className="h-10! w-full justify-start gap-0.5 overflow-x-auto border-b">
|
|
{children}
|
|
</TabsList>
|
|
)
|
|
}
|
|
|
|
export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) {
|
|
return (
|
|
<TabsTrigger value={value} className="gap-1.5 px-4 text-sm after:-bottom-px!">
|
|
{label}
|
|
{count !== undefined ? <span className="text-muted-foreground font-mono text-xs font-normal">{count}</span> : null}
|
|
</TabsTrigger>
|
|
)
|
|
}
|
|
|
|
/** Строка настройки как на досках: слева название и описание, справа элемент управления. */
|
|
export 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.5 last:border-b-0">
|
|
<div className="flex min-w-0 flex-1 basis-64 flex-col gap-0.5">
|
|
<b className="text-sm font-medium">{title}</b>
|
|
<span className="text-muted-foreground text-[13px]">{description}</span>
|
|
</div>
|
|
<div className="shrink-0">{children}</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Frame с заголовком, итоговой плашкой справа и строками настроек (блок «form» досок). */
|
|
export function FormFrame({
|
|
title,
|
|
description,
|
|
badge,
|
|
tone = 'ok',
|
|
children,
|
|
}: {
|
|
title: string
|
|
description: string
|
|
badge: string
|
|
tone?: Tone
|
|
children: ReactNode
|
|
}) {
|
|
return (
|
|
<Frame className="[--frame-panel-header-py-adjust:4px] w-full min-w-0">
|
|
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
|
|
<div className="flex min-w-0 flex-col gap-0.5">
|
|
<FrameTitle>{title}</FrameTitle>
|
|
<FrameDescription>{description}</FrameDescription>
|
|
</div>
|
|
<StatusBadge tone={tone}>{badge}</StatusBadge>
|
|
</FrameHeader>
|
|
<FramePanel className="p-0">{children}</FramePanel>
|
|
</Frame>
|
|
)
|
|
}
|