feat(design-preview): привести макет к дизайн-системе shnt Ops
Шрифты Geist и Geist Mono (только пока смонтирован макет), плашки статуса с глифом и точкой в первой колонке, счётчики в видах таблиц с запоминанием вида, закреплённые пункты меню, автосворачивание меню до 1100px, индикатор свежести в шапке, вызов палитры кнопкой только на узких экранах, главная кнопка в Sheet и предупреждение «Закрыть без сохранения». Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bd69b27e5c
commit
67a0510eeb
15 files changed
+292
-66
No files matched your search
@@ -16,6 +16,8 @@
|
||||
"@authportal/shared": "workspace:*",
|
||||
"@authportal/ui": "workspace:*",
|
||||
"@base-ui/react": "^1.5.0",
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@fontsource-variable/geist-mono": "^5.3.0",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
|
||||
@@ -181,7 +181,6 @@ export function AccessDialog({
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={lines.length === 0}
|
||||
onClick={() => {
|
||||
onSave(next)
|
||||
|
||||
@@ -35,9 +35,14 @@ import {
|
||||
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>
|
||||
)
|
||||
@@ -122,8 +127,21 @@ export function PreviewSheet({
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
}) {
|
||||
const [touched, setTouched] = useState(false)
|
||||
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={onOpenChange}>
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={requestChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
@@ -150,12 +168,53 @@ export function PreviewSheet({
|
||||
{description}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">{children}</div>
|
||||
<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 justify-end gap-2 border-t px-4 py-3">{footer}</SheetFooter>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
@import '@fontsource-variable/geist';
|
||||
@import '@fontsource-variable/geist-mono';
|
||||
|
||||
/* Плотность и контраст макета. Атрибуты ставит PreviewProvider и снимает при выходе. */
|
||||
html[data-preview-density='compact'] [data-slot='data-grid-table'] :is(th, td) {
|
||||
padding-block: 0.25rem;
|
||||
@@ -12,3 +15,14 @@ html[data-preview-contrast='high'] {
|
||||
--border: color-mix(in oklch, var(--foreground) 32%, var(--background));
|
||||
--input: color-mix(in oklch, var(--foreground) 40%, var(--background));
|
||||
}
|
||||
|
||||
/* Шрифты дизайн-системы (Geist / Geist Mono) — только пока смонтирован макет. */
|
||||
html[data-preview-density] {
|
||||
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: 'Geist Mono Variable', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
html[data-preview-density],
|
||||
html[data-preview-density] body {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export function AppLinksPage() {
|
||||
|
||||
const columns = useMemo<GridColumn<LinkRow>[]>(
|
||||
() => [
|
||||
{ id: 'name', title: 'Приложение', size: 260, cell: (r) => <TwoLineCell title={r.name} sub={r.id} /> },
|
||||
{ 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> },
|
||||
@@ -193,7 +193,6 @@ export function AppLinksPage() {
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setEditing(null)}>Отмена</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditing(null)
|
||||
toast.success('Приложение сохранено (макет)')
|
||||
|
||||
@@ -24,7 +24,7 @@ function whenLabel(iso: string) {
|
||||
}
|
||||
|
||||
const columns: GridColumn<AuditLogEntry>[] = [
|
||||
{ id: 'action', title: 'Событие', size: 340, sortValue: (r) => r.action, cell: (r) => <TwoLineCell title={<span className="font-mono text-[13px]">{r.action}</span>} sub={r.summary} /> },
|
||||
{ id: 'action', title: 'Событие', size: 340, sortValue: (r) => r.action, cell: (r) => <TwoLineCell dot={SEVERITY_TONE[r.severity]} title={<span className="font-mono text-[13px]">{r.action}</span>} sub={r.summary} /> },
|
||||
{ id: 'sev', title: 'Важность', size: 120, sortValue: (r) => r.severity, cell: (r) => <StatusBadge tone={SEVERITY_TONE[r.severity]}>{SEVERITY_LABEL[r.severity]}</StatusBadge> },
|
||||
{ id: 'src', title: 'Источник', size: 160, sortValue: (r) => r.source_app, cell: (r) => <span className="text-sm">{sourceLabel(r)}</span> },
|
||||
{ id: 'who', title: 'Кто', size: 140, cell: (r) => <span className="text-sm">{r.actor_name ?? '—'}</span> },
|
||||
|
||||
@@ -20,7 +20,7 @@ export function LoginsPage() {
|
||||
|
||||
const loginCols = useMemo<GridColumn<LoginRow>[]>(
|
||||
() => [
|
||||
{ id: 'login', title: 'Логин', size: 260, sortValue: (r) => r.login, cell: (r) => <TwoLineCell title={r.login} sub={r.client} /> },
|
||||
{ id: 'login', title: 'Логин', size: 260, sortValue: (r) => r.login, cell: (r) => <TwoLineCell dot={r.tone} title={r.login} sub={r.client} /> },
|
||||
{ id: 'status', title: 'Результат', size: 120, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
|
||||
{ id: 'time', title: 'Время', size: 130, cell: (r) => <span className="text-sm whitespace-nowrap">{r.time}</span> },
|
||||
{ id: 'ip', title: 'IP', size: 150, sortValue: (r) => r.ip, cell: (r) => <span className="font-mono text-[13px]">{r.ip}</span> },
|
||||
@@ -32,7 +32,7 @@ export function LoginsPage() {
|
||||
|
||||
const sessionCols = useMemo<GridColumn<SessionRow>[]>(
|
||||
() => [
|
||||
{ id: 'user', title: 'Пользователь', size: 240, sortValue: (r) => r.user, cell: (r) => <TwoLineCell title={r.user} sub={r.note || undefined} /> },
|
||||
{ id: 'user', title: 'Пользователь', size: 240, sortValue: (r) => r.user, cell: (r) => <TwoLineCell dot={r.tone} title={r.user} sub={r.note || undefined} /> },
|
||||
{ id: 'status', title: 'Статус', size: 110, cell: (r) => <StatusBadge tone={r.tone}>{r.status}</StatusBadge> },
|
||||
{ id: 'ip', title: 'IP · клиент', size: 260, cell: (r) => <span className="text-sm">{r.ipClient}</span> },
|
||||
{ id: 'created', title: 'Создана', size: 140, cell: (r) => <span className="text-sm whitespace-nowrap">{r.created}</span> },
|
||||
|
||||
@@ -45,7 +45,7 @@ export function OidcPage() {
|
||||
|
||||
const columns = useMemo<GridColumn<OidcClientRow>[]>(
|
||||
() => [
|
||||
{ id: 'name', title: 'Клиент', size: 260, sortValue: (r) => r.name, cell: (r) => <TwoLineCell title={r.name} sub={r.hint} /> },
|
||||
{ id: 'name', title: 'Клиент', size: 260, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={tone(r)} title={r.name} sub={r.hint} /> },
|
||||
{ id: 'status', title: 'Статус', size: 150, cell: (r) => <StatusBadge tone={tone(r)}>{statusLabel(r)}</StatusBadge> },
|
||||
{ id: 'cid', title: 'client_id', size: 150, cell: (r) => <code className="font-mono text-[13px]">{r.clientId}</code> },
|
||||
{ id: 'redirect', title: 'Redirect URI', size: 320, cell: (r) => <code className="block truncate font-mono text-[13px]">{r.redirect}</code> },
|
||||
@@ -159,7 +159,6 @@ export function OidcPage() {
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setEditing(null)}>Закрыть</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditing(null)
|
||||
toast.success('Клиент сохранён (макет)')
|
||||
|
||||
@@ -23,7 +23,7 @@ export function SecurityPage() {
|
||||
|
||||
const columns = useMemo<GridColumn<PasskeyRow>[]>(
|
||||
() => [
|
||||
{ id: 'name', title: 'Passkey', size: 280, sortValue: (r) => r.name, cell: (r) => <TwoLineCell title={r.name} sub={r.note} /> },
|
||||
{ id: 'name', title: 'Passkey', size: 280, sortValue: (r) => r.name, cell: (r) => <TwoLineCell dot={r.tone} title={r.name} sub={r.note} /> },
|
||||
{ id: 'kind', title: 'Тип', size: 160, cell: (r) => <span className="text-sm">{r.kind}</span> },
|
||||
{ id: 'added', title: 'Добавлен', size: 120, cell: (r) => <span className="text-sm tabular-nums">{r.added}</span> },
|
||||
{ id: 'last', title: 'Последний вход', size: 160, cell: (r) => <span className="text-sm">{r.lastUsed}</span> },
|
||||
@@ -189,7 +189,6 @@ export function SecurityPage() {
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setSelected(null)}>Закрыть</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setKeys((k) => k.map((x) => (x.id === selected?.id ? { ...x, name: renameValue } : x)))
|
||||
setSelected(null)
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
RowsIcon,
|
||||
SearchIcon,
|
||||
ServerIcon,
|
||||
StarIcon,
|
||||
ShieldIcon,
|
||||
SunIcon,
|
||||
UserPlusIcon,
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@authportal/ui/components/dropdown-menu'
|
||||
import { Kbd } from '@authportal/ui/components/kbd'
|
||||
import { Switch } from '@authportal/ui/components/switch'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@authportal/ui/components/popover'
|
||||
import { Separator } from '@authportal/ui/components/separator'
|
||||
import {
|
||||
@@ -61,6 +63,7 @@ import {
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarSeparator,
|
||||
@@ -68,7 +71,7 @@ import {
|
||||
useSidebar,
|
||||
} from '@authportal/ui/components/sidebar'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import { APP_SHORT, MOCK_USERS, MONITOR_ITEMS, NAV_GROUPS, NAV_ITEMS } from './mock'
|
||||
import { APP_SHORT, MOCK_USERS, MONITOR_ITEMS, NAV_GROUPS, NAV_ITEMS, type NavItem } from './mock'
|
||||
import { usePreviewStore } from './preview-store'
|
||||
|
||||
const APP_ICON: Record<AppId, typeof CloudIcon> = {
|
||||
@@ -297,51 +300,78 @@ function PreviewNavUser() {
|
||||
|
||||
const DOT_CLASS = { er: 'bg-destructive', wn: 'bg-warning' } as const
|
||||
|
||||
function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; pathname: string; pinned: boolean; onTogglePin: () => void }) {
|
||||
const Icon = item.icon
|
||||
const active = pathname === item.to || pathname.startsWith(`${item.to}/`)
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.problem ? `${item.label} · ${item.problem.label}` : item.label}
|
||||
isActive={active}
|
||||
render={<Link to={item.to} />}
|
||||
>
|
||||
<span className="relative flex shrink-0">
|
||||
<Icon className="size-4" />
|
||||
{item.problem ? (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={item.problem.label}
|
||||
className={cn('ring-sidebar absolute -top-1 -right-1 size-2 rounded-full ring-2', DOT_CLASS[item.problem.tone])}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
<SidebarMenuAction
|
||||
showOnHover={!pinned}
|
||||
aria-label={pinned ? `Открепить ${item.label}` : `Закрепить ${item.label}`}
|
||||
aria-pressed={pinned}
|
||||
onClick={onTogglePin}
|
||||
>
|
||||
<StarIcon className={cn(pinned && 'fill-current')} aria-hidden />
|
||||
</SidebarMenuAction>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export function PreviewSidebar() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const { pins, togglePin } = usePreviewStore()
|
||||
const pinned = NAV_ITEMS.filter((i) => pins.includes(i.id))
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<PreviewAppSwitcher />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{pinned.length > 0 ? (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel>Закреплённые</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{pinned.map((item) => (
|
||||
<NavEntry key={item.id} item={item} pathname={pathname} pinned onTogglePin={() => togglePin(item.id)} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
) : null}
|
||||
{NAV_GROUPS.map((group, gi) => (
|
||||
<div key={group} className="flex flex-col">
|
||||
{gi > 0 ? (
|
||||
<SidebarSeparator className="mx-2 my-1 hidden group-data-[collapsible=icon]:block" />
|
||||
) : null}
|
||||
{gi > 0 ? <SidebarSeparator className="mx-2 my-1 hidden group-data-[collapsible=icon]:block" /> : null}
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{group}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{NAV_ITEMS.filter((i) => i.group === group).map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = pathname === item.to || pathname.startsWith(`${item.to}/`)
|
||||
return (
|
||||
<SidebarMenuItem key={item.id}>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.problem ? `${item.label} · ${item.problem.label}` : item.label}
|
||||
isActive={active}
|
||||
render={<Link to={item.to} />}
|
||||
>
|
||||
<span className="relative flex shrink-0">
|
||||
<Icon className="size-4" />
|
||||
{item.problem ? (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={item.problem.label}
|
||||
className={cn(
|
||||
'ring-sidebar absolute -top-1 -right-1 size-2 rounded-full ring-2',
|
||||
DOT_CLASS[item.problem.tone],
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
{NAV_ITEMS.filter((i) => i.group === group).map((item) => (
|
||||
<NavEntry
|
||||
key={item.id}
|
||||
item={item}
|
||||
pathname={pathname}
|
||||
pinned={pins.includes(item.id)}
|
||||
onTogglePin={() => togglePin(item.id)}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
@@ -407,6 +437,7 @@ function MonitorPopover() {
|
||||
export function PreviewHeader() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const { setPaletteOpen } = usePreviewStore()
|
||||
const [auto, setAuto] = useState(true)
|
||||
const current = NAV_ITEMS.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
|
||||
|
||||
return (
|
||||
@@ -425,15 +456,21 @@ export function PreviewHeader() {
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
<span role="status" className="text-muted-foreground hidden items-center gap-1.5 text-xs @3xl/header:flex">
|
||||
<span className="bg-success size-1.5 rounded-full" aria-hidden />
|
||||
Обновлено 14:02:11 · Live
|
||||
<Switch size="sm" checked={auto} onCheckedChange={setAuto} aria-label="Автообновление" />
|
||||
<span>авто</span>
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Командная палитра"
|
||||
size="icon-sm"
|
||||
aria-label="Командная палитра (⌘K)"
|
||||
className="md:hidden"
|
||||
onClick={() => setPaletteOpen(true)}
|
||||
>
|
||||
<SearchIcon aria-hidden />
|
||||
<Kbd className="hidden @xl/header:inline-flex">⌘K</Kbd>
|
||||
</Button>
|
||||
<MonitorPopover />
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,25 @@ export function PreviewProvider({ children }: { children: ReactNode }) {
|
||||
const [paletteOpen, setPaletteOpen] = useState(false)
|
||||
const [density, setDensity] = useState<Density>('comfortable')
|
||||
const [contrast, setContrast] = useState<Contrast>('standard')
|
||||
const [pins, setPins] = useState<string[]>(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem('design-preview:pins')
|
||||
return raw ? (JSON.parse(raw) as string[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
const togglePin = useCallback((id: string) => {
|
||||
setPins((prev) => {
|
||||
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
try {
|
||||
window.localStorage.setItem('design-preview:pins', JSON.stringify(next))
|
||||
} catch {
|
||||
/* без сохранения закреплённых */
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
const seq = useRef(0)
|
||||
const pendingRef = useRef(pending)
|
||||
useEffect(() => {
|
||||
@@ -60,7 +79,7 @@ export function PreviewProvider({ children }: { children: ReactNode }) {
|
||||
const count = pendingRef.current.length
|
||||
setPending([])
|
||||
toast.success(`Сохранено изменений: ${count}`, {
|
||||
description: 'Макет: записи в журнал не отправляются.',
|
||||
description: 'Записано в портал и журнал «Изменения» (макет: ничего не отправляется).',
|
||||
})
|
||||
}, [])
|
||||
|
||||
@@ -72,12 +91,14 @@ export function PreviewProvider({ children }: { children: ReactNode }) {
|
||||
applyAll,
|
||||
paletteOpen,
|
||||
setPaletteOpen,
|
||||
pins,
|
||||
togglePin,
|
||||
density,
|
||||
setDensity,
|
||||
contrast,
|
||||
setContrast,
|
||||
}),
|
||||
[pending, addChange, discardAll, applyAll, paletteOpen, density, contrast],
|
||||
[pending, addChange, discardAll, applyAll, paletteOpen, pins, togglePin, density, contrast],
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,7 +9,16 @@ import './design-preview.css'
|
||||
/** Оболочка макета: сайдбар, шапка, палитра и панель «Не сохранено». Монтируется после гидрации. */
|
||||
export function PreviewShell({ children }: { children: ReactNode }) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
const [open, setOpen] = useState(true)
|
||||
useEffect(() => {
|
||||
// До 1100px меню сворачивается в колонку иконок (на мобильных его заменяет Sheet).
|
||||
const mq = window.matchMedia('(min-width: 1100px)')
|
||||
const sync = () => setOpen(mq.matches)
|
||||
sync()
|
||||
mq.addEventListener('change', sync)
|
||||
setMounted(true)
|
||||
return () => mq.removeEventListener('change', sync)
|
||||
}, [])
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
@@ -22,7 +31,7 @@ export function PreviewShell({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<TooltipProvider delay={0}>
|
||||
<SidebarProvider style={{ '--sidebar-width': '240px' } as CSSProperties}>
|
||||
<SidebarProvider open={open} onOpenChange={setOpen} style={{ '--sidebar-width': '240px' } as CSSProperties}>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="bg-background text-foreground ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:ring-2"
|
||||
|
||||
@@ -22,6 +22,8 @@ export type PreviewStore = {
|
||||
applyAll: () => void
|
||||
paletteOpen: boolean
|
||||
setPaletteOpen: (open: boolean) => void
|
||||
pins: string[]
|
||||
togglePin: (id: string) => void
|
||||
density: Density
|
||||
setDensity: (v: Density) => void
|
||||
contrast: Contrast
|
||||
|
||||
@@ -31,6 +31,8 @@ import { Separator } from '@authportal/ui/components/separator'
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from '@authportal/ui/components/input-group'
|
||||
import { cn } from '@authportal/ui/lib/utils'
|
||||
import { Segmented } from './bits'
|
||||
import type { Tone } from './mock'
|
||||
import { TONE_DOT } from './tone'
|
||||
|
||||
export type GridColumn<T> = {
|
||||
id: string
|
||||
@@ -84,7 +86,23 @@ export function PreviewSectionGrid<T extends object>({
|
||||
emptyMessage?: string
|
||||
className?: string
|
||||
}) {
|
||||
const [view, setView] = useState(0)
|
||||
const storageKey = `design-preview:view:${title}`
|
||||
const [view, setViewState] = useState(() => {
|
||||
try {
|
||||
const v = Number(window.localStorage.getItem(storageKey))
|
||||
return Number.isInteger(v) && v >= 0 && v < (views?.length ?? 0) ? v : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
})
|
||||
const setView = (i: number) => {
|
||||
setViewState(i)
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, String(i))
|
||||
} catch {
|
||||
/* без сохранения вида */
|
||||
}
|
||||
}
|
||||
const [query, setQuery] = useState('')
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
@@ -170,7 +188,7 @@ export function PreviewSectionGrid<T extends object>({
|
||||
{views ? (
|
||||
<Segmented
|
||||
label="Вид"
|
||||
options={views.map((v) => v.label)}
|
||||
options={views.map((v) => `${v.label} · ${rows.filter(v.match).length}`)}
|
||||
value={view}
|
||||
onChange={(i) => {
|
||||
setView(i)
|
||||
@@ -233,11 +251,14 @@ export function PreviewSectionGrid<T extends object>({
|
||||
}
|
||||
|
||||
/** Двухстрочная ячейка: заголовок + подпись. */
|
||||
export function TwoLineCell({ title, sub }: { title: ReactNode; sub?: ReactNode }) {
|
||||
export function TwoLineCell({ title, sub, dot }: { title: ReactNode; sub?: ReactNode; dot?: Tone }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium">{title}</span>
|
||||
{sub ? <span className="text-muted-foreground truncate text-xs">{sub}</span> : null}
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
{dot ? <span aria-hidden className={cn('mt-1.5 size-2 shrink-0 rounded-full', TONE_DOT[dot])} /> : null}
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium">{title}</span>
|
||||
{sub ? <span className="text-muted-foreground truncate font-mono text-[11px]">{sub}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Generated
+73
-8
@@ -129,7 +129,7 @@ importers:
|
||||
version: 8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.10(@types/node@22.20.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1))
|
||||
version: 4.1.10(@types/node@22.20.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))
|
||||
|
||||
apps/web:
|
||||
dependencies:
|
||||
@@ -142,6 +142,12 @@ importers:
|
||||
'@base-ui/react':
|
||||
specifier: ^1.5.0
|
||||
version: 1.6.0(@date-fns/tz@1.5.0)(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@fontsource-variable/geist':
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
'@fontsource-variable/geist-mono':
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.4.0
|
||||
version: 5.4.0(react-hook-form@7.82.0(react@19.2.7))
|
||||
@@ -303,7 +309,7 @@ importers:
|
||||
version: 8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3)
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))
|
||||
version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1))
|
||||
|
||||
packages/ui:
|
||||
dependencies:
|
||||
@@ -1261,6 +1267,12 @@ packages:
|
||||
'@floating-ui/utils@0.2.12':
|
||||
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
|
||||
|
||||
'@fontsource-variable/geist-mono@5.3.0':
|
||||
resolution: {integrity: sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw==}
|
||||
|
||||
'@fontsource-variable/geist@5.3.0':
|
||||
resolution: {integrity: sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==}
|
||||
|
||||
'@hexagon/base64@1.1.28':
|
||||
resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==}
|
||||
|
||||
@@ -5705,6 +5717,10 @@ snapshots:
|
||||
|
||||
'@floating-ui/utils@0.2.12': {}
|
||||
|
||||
'@fontsource-variable/geist-mono@5.3.0': {}
|
||||
|
||||
'@fontsource-variable/geist@5.3.0': {}
|
||||
|
||||
'@hexagon/base64@1.1.28': {}
|
||||
|
||||
'@hookform/resolvers@5.4.0(react-hook-form@7.82.0(react@19.2.7))':
|
||||
@@ -6666,13 +6682,21 @@ snapshots:
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.1.10(vite@8.1.5(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1))':
|
||||
'@vitest/mocker@4.1.10(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.10
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 8.1.5(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)
|
||||
vite: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)
|
||||
|
||||
'@vitest/mocker@4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.10
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)
|
||||
|
||||
'@vitest/mocker@4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))':
|
||||
dependencies:
|
||||
@@ -9121,7 +9145,7 @@ snapshots:
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
vite@8.1.5(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1):
|
||||
vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.5
|
||||
@@ -9130,6 +9154,20 @@ snapshots:
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
'@types/node': 22.20.1
|
||||
esbuild: 0.28.1
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.7.0
|
||||
tsx: 4.23.1
|
||||
|
||||
vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.5
|
||||
postcss: 8.5.19
|
||||
rolldown: 1.1.5
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
'@types/node': 24.13.3
|
||||
esbuild: 0.27.7
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.7.0
|
||||
@@ -9149,10 +9187,10 @@ snapshots:
|
||||
jiti: 2.7.0
|
||||
tsx: 4.23.1
|
||||
|
||||
vitest@4.1.10(@types/node@22.20.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)):
|
||||
vitest@4.1.10(@types/node@22.20.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.10
|
||||
'@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1))
|
||||
'@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1))
|
||||
'@vitest/pretty-format': 4.1.10
|
||||
'@vitest/runner': 4.1.10
|
||||
'@vitest/snapshot': 4.1.10
|
||||
@@ -9169,13 +9207,40 @@ snapshots:
|
||||
tinyexec: 1.2.4
|
||||
tinyglobby: 0.2.17
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 8.1.5(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)
|
||||
vite: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 22.20.1
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
vitest@4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.10
|
||||
'@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1))
|
||||
'@vitest/pretty-format': 4.1.10
|
||||
'@vitest/runner': 4.1.10
|
||||
'@vitest/snapshot': 4.1.10
|
||||
'@vitest/spy': 4.1.10
|
||||
'@vitest/utils': 4.1.10
|
||||
es-module-lexer: 2.3.1
|
||||
expect-type: 1.4.0
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.4
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.5
|
||||
std-env: 4.2.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 1.2.4
|
||||
tinyglobby: 0.2.17
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 24.13.3
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
vitest@4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.10
|
||||
|
||||
Reference in new issue
Block a user