fix(design-preview): размеры, отступы и пороги ширины как на досках Auth Portal

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-18 14:53:25 +07:00
1 parent 3e1ad15ba3
commit b82c8031c2
12 files changed
+89 -60

No files matched your search

+10 -11
View File
@@ -60,10 +60,10 @@ export function PageHead({
actions?: ReactNode actions?: ReactNode
}) { }) {
return ( return (
<header className="@container flex flex-wrap items-end justify-between gap-x-4 gap-y-3"> <header className="flex flex-wrap items-end justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1"> <div className="flex min-w-0 flex-col gap-0.5">
<h1 className="text-2xl font-semibold tracking-tight text-balance">{title}</h1> <h1 className="text-xl font-semibold tracking-[-0.01em]">{title}</h1>
<p className="text-muted-foreground max-w-prose text-sm text-pretty">{sub}</p> <p className="text-muted-foreground text-sm">{sub}</p>
</div> </div>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null} {actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</header> </header>
@@ -298,7 +298,7 @@ export function KvFrame({
items: Array<{ label: string; value: ReactNode }> items: Array<{ label: string; value: ReactNode }>
}) { }) {
return ( return (
<Frame spacing="sm" className="w-full"> <Frame className="[--frame-panel-header-py-adjust:4px] w-full">
<FrameHeader> <FrameHeader>
<FrameTitle>{title}</FrameTitle> <FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription> <FrameDescription>{description}</FrameDescription>
@@ -331,7 +331,7 @@ export function CopyList({
items: Array<{ label: string; value: string }> items: Array<{ label: string; value: string }>
}) { }) {
return ( return (
<Frame spacing="sm" className="w-full"> <Frame className="[--frame-panel-header-py-adjust:4px] w-full">
<FrameHeader> <FrameHeader>
<FrameTitle>{title}</FrameTitle> <FrameTitle>{title}</FrameTitle>
<FrameDescription>{description}</FrameDescription> <FrameDescription>{description}</FrameDescription>
@@ -347,7 +347,6 @@ export function CopyList({
<span className="text-muted-foreground min-w-0 flex-1 basis-48 text-[13px]">{item.label}</span> <span className="text-muted-foreground min-w-0 flex-1 basis-48 text-[13px]">{item.label}</span>
<Button <Button
type="button" type="button"
size="sm"
variant="outline" variant="outline"
aria-label={`Скопировать: ${item.label}`} aria-label={`Скопировать: ${item.label}`}
onClick={() => { onClick={() => {
@@ -425,7 +424,7 @@ export function PendingChip({ mark }: { mark: 'new' | 'dirty' | 'deleted' }) {
/** Вкладки страницы как на досках: линия-подчёркивание, число — мелким индексом. */ /** Вкладки страницы как на досках: линия-подчёркивание, число — мелким индексом. */
export function PageTabsList({ children }: { children: ReactNode }) { export function PageTabsList({ children }: { children: ReactNode }) {
return ( return (
<TabsList variant="line" className="h-10! w-full justify-start gap-6 overflow-x-auto border-b"> <TabsList variant="line" className="h-10! w-full justify-start gap-0.5 overflow-x-auto border-b">
{children} {children}
</TabsList> </TabsList>
) )
@@ -433,9 +432,9 @@ export function PageTabsList({ children }: { children: ReactNode }) {
export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) { export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) {
return ( return (
<TabsTrigger value={value} className="px-1 text-sm after:-bottom-px!"> <TabsTrigger value={value} className="gap-1.5 px-4 text-sm after:-bottom-px!">
{label} {label}
{count !== undefined ? <sup className="text-muted-foreground ml-1 text-[10px] font-medium tabular-nums">{count}</sup> : null} {count !== undefined ? <em className="font-mono text-[10px] font-normal not-italic opacity-60">{count}</em> : null}
</TabsTrigger> </TabsTrigger>
) )
} }
@@ -468,7 +467,7 @@ export function FormFrame({
children: ReactNode children: ReactNode
}) { }) {
return ( return (
<Frame spacing="sm" className="w-full min-w-0"> <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"> <FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle> <FrameTitle>{title}</FrameTitle>
@@ -33,7 +33,7 @@ export function AppCards({
action?: ReactNode action?: ReactNode
}) { }) {
return ( return (
<Frame spacing="sm" className="w-full min-w-0"> <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"> <FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle> <FrameTitle>{title}</FrameTitle>
@@ -109,7 +109,7 @@ export function ActivityFeed({
action?: ReactNode action?: ReactNode
}) { }) {
return ( return (
<Frame spacing="sm" className="w-full min-w-0"> <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"> <FrameHeader className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle> <FrameTitle>{title}</FrameTitle>
+35 -8
View File
@@ -13,19 +13,25 @@ html[data-preview-density='compact'] [data-slot='sidebar-inset'] > div:last-chil
/* Палитра MikrotikManager v2 (доски Auth Portal): только пока смонтирован макет. */ /* Палитра MikrotikManager v2 (доски Auth Portal): только пока смонтирован макет. */
html[data-preview-density] { html[data-preview-density] {
--background: oklch(0.973 0.004 247.858); --background: oklch(0.973 0.004 247.858);
--foreground: oklch(0.205 0.01 264); --foreground: oklch(0.141 0.006 264);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.006 264);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--secondary: oklch(0.955 0.005 264); --popover-foreground: oklch(0.141 0.006 264);
--muted: oklch(0.95 0.006 264); --secondary: oklch(0.958 0.005 247.858);
--muted-foreground: oklch(0.44 0.02 264); --secondary-foreground: oklch(0.205 0 0);
--accent: oklch(0.93 0.01 264); --muted: oklch(0.958 0.005 247.858);
--border: oklch(0.88 0.008 264); --muted-foreground: oklch(0.52 0.01 264);
--input: oklch(0.86 0.008 264); --accent: oklch(0.942 0.016 264.376);
--accent-foreground: oklch(0.205 0 0);
--border: oklch(0.904 0.006 264);
--input: oklch(0.904 0.006 264);
--ring: oklch(0.488 0.243 264.376); --ring: oklch(0.488 0.243 264.376);
--sidebar: oklch(0.985 0.003 264); --sidebar: oklch(0.985 0.003 264);
--sidebar-foreground: oklch(0.141 0.006 264);
--sidebar-accent: oklch(0.93 0.01 264); --sidebar-accent: oklch(0.93 0.01 264);
--sidebar-border: oklch(0.88 0.008 264); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.904 0.006 264);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.488 0.243 264.376);
--destructive-foreground: oklch(0.45 0.2 27); --destructive-foreground: oklch(0.45 0.2 27);
--success: oklch(0.6 0.15 163); --success: oklch(0.6 0.15 163);
@@ -40,16 +46,22 @@ html[data-preview-density].dark {
--background: oklch(0.133 0.011 264.376); --background: oklch(0.133 0.011 264.376);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.191 0.011 264.376); --card: oklch(0.191 0.011 264.376);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.191 0.011 264.376); --popover: oklch(0.191 0.011 264.376);
--popover-foreground: oklch(0.985 0 0);
--secondary: oklch(0.252 0.01 264.376); --secondary: oklch(0.252 0.01 264.376);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.252 0.01 264.376); --muted: oklch(0.252 0.01 264.376);
--muted-foreground: oklch(0.648 0.008 264.376); --muted-foreground: oklch(0.648 0.008 264.376);
--accent: oklch(0.285 0.022 264.376); --accent: oklch(0.285 0.022 264.376);
--accent-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.488 0.243 264.376); --ring: oklch(0.488 0.243 264.376);
--sidebar: oklch(0.191 0.011 264.376); --sidebar: oklch(0.191 0.011 264.376);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.262 0.014 264.376); --sidebar-accent: oklch(0.262 0.014 264.376);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(0.577 0.245 27.325);
@@ -70,6 +82,15 @@ html[data-preview-density][data-preview-contrast='high'] {
html[data-preview-density].dark[data-preview-contrast='high'] { html[data-preview-density].dark[data-preview-contrast='high'] {
--background: oklch(0 0 0); --background: oklch(0 0 0);
--foreground: oklch(1 0 0);
--success: oklch(0.82 0.19 150);
--success-foreground: oklch(0.9 0.15 150);
--warning: oklch(0.9 0.17 95);
--warning-foreground: oklch(0.95 0.12 95);
--destructive: oklch(0.78 0.2 25);
--destructive-foreground: oklch(0.88 0.12 25);
--info: oklch(0.8 0.15 300);
--info-foreground: oklch(0.9 0.1 300);
--card: oklch(0.12 0 0); --card: oklch(0.12 0 0);
--popover: oklch(0.1 0 0); --popover: oklch(0.1 0 0);
--muted: oklch(0.22 0 0); --muted: oklch(0.22 0 0);
@@ -104,3 +125,9 @@ html[data-preview-density] #main-content :focus-visible {
transition: none !important; transition: none !important;
} }
} }
/* Шапка таблицы как на досках: 12px, лёгкая подложка. */
html[data-preview-density] [data-slot='data-grid-table'] thead th {
font-size: 0.75rem;
background: color-mix(in oklch, var(--muted) 30%, transparent);
}
@@ -34,7 +34,7 @@ export function ExplainBanner({
<div className="min-w-0 flex-1 basis-80 text-pretty"> <div className="min-w-0 flex-1 basis-80 text-pretty">
<b className="font-semibold">{title}</b> {children} <b className="font-semibold">{title}</b> {children}
</div> </div>
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}> <Button type="button" variant="outline" onClick={() => setOpen(true)}>
Объяснить Объяснить
</Button> </Button>
</div> </div>
+4 -4
View File
@@ -11,12 +11,12 @@ const VALUE: Record<KpiTone, string> = {
er: 'text-destructive', er: 'text-destructive',
} }
/** Колонки по числу ячеек: 4 → 4/2/1, 3 → 3/1, 2 → 2/1 (пороги контейнера как на досках). */ /** Колонки по числу ячеек и ширине окна (на досках — ширина приложения): 4 → 4/2/1, 3 → 3/1, 2 → 2/1. */
const COLS: Record<number, string> = { const COLS: Record<number, string> = {
1: 'grid-cols-1', 1: 'grid-cols-1',
2: 'grid-cols-1 @[640px]:grid-cols-2', 2: 'grid-cols-2 max-[640px]:grid-cols-1',
3: 'grid-cols-1 @[1240px]:grid-cols-3', 3: 'grid-cols-1 min-[1241px]:grid-cols-3',
4: 'grid-cols-1 @[640px]:grid-cols-2 @[1240px]:grid-cols-4', 4: 'grid-cols-1 min-[641px]:grid-cols-2 min-[1241px]:grid-cols-4',
} }
const GLYPH: Record<KpiTone, string> = { neutral: '', ok: '●', in: '', wn: '▲', er: '✕' } const GLYPH: Record<KpiTone, string> = { neutral: '', ok: '●', in: '', wn: '▲', er: '✕' }
@@ -202,7 +202,7 @@ export function AppLinksPage() {
{ label: 'Скрытые', match: (r) => !r.enabled }, { label: 'Скрытые', match: (r) => !r.enabled },
]} ]}
tools={ tools={
<Button type="button" size="sm" variant="outline" onClick={resetOrder}> <Button type="button" variant="outline" onClick={resetOrder}>
Сбросить порядок Сбросить порядок
</Button> </Button>
} }
@@ -66,7 +66,7 @@ export function AuditPage() {
description="Последние события · иконка отражает тип, цвет — важность" description="Последние события · иконка отражает тип, цвет — важность"
items={AUDIT_FEED} items={AUDIT_FEED}
action={ action={
<Button variant="outline" size="sm" onClick={() => setTab('table')}> <Button variant="outline" onClick={() => setTab('table')}>
Весь журнал Весь журнал
</Button> </Button>
} }
@@ -127,7 +127,6 @@ export function OidcPage() {
tools={ tools={
<Button <Button
type="button" type="button"
size="sm"
variant="outline" variant="outline"
onClick={() => setRotating(clients.find((c) => c.stale) ?? clients[0] ?? null)} onClick={() => setRotating(clients.find((c) => c.stale) ?? clients[0] ?? null)}
> >
+14 -14
View File
@@ -266,7 +266,7 @@ export function PreviewSidebar() {
const pinned = NAV_ITEMS.filter((i) => pins.includes(i.id)) const pinned = NAV_ITEMS.filter((i) => pins.includes(i.id))
return ( return (
<Sidebar collapsible="icon"> <Sidebar collapsible="icon">
<SidebarHeader> <SidebarHeader className="pb-1.5">
<PreviewAppSwitcher /> <PreviewAppSwitcher />
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
@@ -285,7 +285,7 @@ export function PreviewSidebar() {
{NAV_GROUPS.map((group, gi) => ( {NAV_GROUPS.map((group, gi) => (
<div key={group} className="flex flex-col"> <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> <SidebarGroup className="py-0">
<SidebarGroupLabel <SidebarGroupLabel
render={ render={
<button <button
@@ -294,13 +294,13 @@ export function PreviewSidebar() {
onClick={() => setClosed((c) => ({ ...c, [group]: !c[group] }))} onClick={() => setClosed((c) => ({ ...c, [group]: !c[group] }))}
/> />
} }
className="cursor-pointer justify-between text-[11px] font-semibold tracking-wide uppercase" className="h-7 cursor-pointer justify-between text-[11px] font-semibold tracking-wide uppercase"
> >
{group} {group}
<ChevronDownIcon className={cn('size-3.5 transition-transform', closed[group] && '-rotate-90')} aria-hidden /> <ChevronDownIcon className={cn('size-3.5 transition-transform', closed[group] && '-rotate-90')} aria-hidden />
</SidebarGroupLabel> </SidebarGroupLabel>
<SidebarGroupContent className={cn(closed[group] && 'group-data-[collapsible=icon]:block hidden')}> <SidebarGroupContent className={cn(closed[group] && 'group-data-[collapsible=icon]:block hidden')}>
<SidebarMenu> <SidebarMenu className="gap-px">
{NAV_ITEMS.filter((i) => i.group === group).map((item) => ( {NAV_ITEMS.filter((i) => i.group === group).map((item) => (
<NavEntry <NavEntry
key={item.id} key={item.id}
@@ -330,7 +330,7 @@ const MONITOR_METRICS = [
{ l: 'Сессии', v: '5', p: 40, tone: 'success' }, { l: 'Сессии', v: '5', p: 40, tone: 'success' },
] as const ] as const
/** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине шапки. */ /** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине окна, как на досках. */
function MonitorPopover() { function MonitorPopover() {
const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok') const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok')
const errors = bad.filter((m) => m.tone === 'er').length const errors = bad.filter((m) => m.tone === 'er').length
@@ -344,22 +344,22 @@ function MonitorPopover() {
variant="outline" variant="outline"
type="button" type="button"
aria-label={`Состояние системы: внимание, ${errors} сбой, ${warnings} предупреждения`} aria-label={`Состояние системы: внимание, ${errors} сбой, ${warnings} предупреждения`}
className="border-warning/40 bg-warning/10 hover:bg-warning/20 aria-expanded:bg-warning/20 relative h-8 gap-2 px-2 @max-[760px]/header:w-8 @max-[760px]/header:border-transparent @max-[760px]/header:bg-transparent @max-[760px]/header:px-0" className="border-warning/40 bg-warning/10 hover:bg-warning/20 aria-expanded:bg-warning/20 relative h-8 gap-2 px-2 max-[760px]:w-8 max-[760px]:border-transparent max-[760px]:bg-transparent max-[760px]:px-0"
/> />
} }
> >
<ActivityIcon className="text-warning size-4" aria-hidden /> <ActivityIcon className="text-warning size-4" aria-hidden />
<span className="text-warning hidden text-sm font-medium @[1000px]/header:inline">Внимание</span> <span className="text-warning hidden text-sm font-medium min-[1001px]:inline">Внимание</span>
<span className="hidden items-center gap-1.5 text-sm tabular-nums @[1240px]/header:inline-flex"> <span className="hidden items-center gap-1.5 text-sm tabular-nums min-[1241px]:inline-flex">
<span className="bg-destructive size-1.5 rounded-full" aria-hidden /> <span className="bg-destructive size-1.5 rounded-full" aria-hidden />
{errors} {errors}
<span className="bg-warning size-1.5 rounded-full" aria-hidden /> <span className="bg-warning size-1.5 rounded-full" aria-hidden />
{warnings} {warnings}
</span> </span>
<span className="bg-destructive hidden size-4.5 min-w-4.5 items-center justify-center rounded-full px-1 text-[11px] font-semibold text-white @[760px]/header:inline-flex @[1240px]/header:hidden"> <span className="bg-destructive hidden size-4.5 min-w-4.5 items-center justify-center rounded-full px-1 text-[11px] font-semibold text-white min-[761px]:inline-flex min-[1241px]:hidden">
{bad.length} {bad.length}
</span> </span>
<span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 @[760px]/header:hidden" aria-hidden /> <span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 min-[761px]:hidden" aria-hidden />
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="end" className="flex w-[22rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2"> <PopoverContent align="end" className="flex w-[22rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2">
<div className="flex items-start justify-between gap-2 px-1.5 pt-1.5"> <div className="flex items-start justify-between gap-2 px-1.5 pt-1.5">
@@ -425,7 +425,7 @@ function FreshnessPopover() {
<Popover> <Popover>
<PopoverTrigger <PopoverTrigger
render={ render={
<Button type="button" variant="ghost" size="icon-sm" aria-label="Источники и свежесть данных" title="Источники и свежесть данных" /> <Button type="button" variant="ghost" size="icon" aria-label="Источники и свежесть данных" title="Источники и свежесть данных" />
} }
> >
<InfoIcon aria-hidden /> <InfoIcon aria-hidden />
@@ -444,7 +444,7 @@ function FreshPill() {
return ( return (
<span <span
role="status" role="status"
className="text-muted-foreground hidden h-[30px] items-center gap-2 rounded-lg border px-2.5 text-xs @[1320px]/header:inline-flex" className="text-muted-foreground hidden h-[30px] items-center gap-2 rounded-lg border px-2.5 text-xs min-[1321px]:inline-flex"
> >
<span className="bg-success size-1.5 rounded-full" aria-hidden /> <span className="bg-success size-1.5 rounded-full" aria-hidden />
Обновлено 14:02 · Live Обновлено 14:02 · Live
@@ -460,7 +460,7 @@ export function PreviewHeader() {
const current = NAV_ITEMS.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`)) const current = NAV_ITEMS.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
return ( return (
<header className="bg-background/80 @container/header sticky top-0 z-20 flex min-h-12 shrink-0 items-center gap-2 border-b px-4 py-1.5 backdrop-blur md:px-6"> <header className="bg-background/80 sticky top-0 z-20 flex min-h-12 shrink-0 items-center gap-2 border-b px-4 py-1.5 backdrop-blur md:px-6">
<SidebarTrigger className="-ml-1" /> <SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" /> <Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<Breadcrumb className="min-w-0"> <Breadcrumb className="min-w-0">
@@ -476,7 +476,7 @@ export function PreviewHeader() {
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="icon-sm" size="icon"
aria-label="Командная палитра (⌘K)" aria-label="Командная палитра (⌘K)"
className="md:hidden" className="md:hidden"
onClick={() => setPaletteOpen(true)} onClick={() => setPaletteOpen(true)}
+16 -12
View File
@@ -222,7 +222,7 @@ export function PreviewSectionGrid<T extends object>({
<Switch size="sm" checked={on} onCheckedChange={(v) => rowSwitch.onChange(r, v)} aria-label="Включено" /> <Switch size="sm" checked={on} onCheckedChange={(v) => rowSwitch.onChange(r, v)} aria-label="Включено" />
) : null} ) : null}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon-sm" aria-label="Действия" />}> <DropdownMenuTrigger render={<Button type="button" variant="ghost" size="icon" aria-label="Действия" />}>
<MoreHorizontalIcon aria-hidden /> <MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-48"> <DropdownMenuContent align="end" className="min-w-48">
@@ -265,9 +265,12 @@ export function PreviewSectionGrid<T extends object>({
onReorder(String(active.id), String(over.id)) onReorder(String(active.id), String(over.id))
} }
const minWidth = visibleColumns.reduce( // Колонки сжимаются под ширину рамки (текст переносится, как на досках); прокрутка — только на узких экранах.
(sum, c) => sum + c.size, const minWidth = Math.round(
(selectable ? SELECT_COLUMN_SIZE : 0) + (onReorder ? DRAG_COLUMN_SIZE : 0) + ACTIONS_COLUMN_SIZE, visibleColumns.reduce(
(sum, c) => sum + c.size,
(selectable ? SELECT_COLUMN_SIZE : 0) + (onReorder ? DRAG_COLUMN_SIZE : 0) + ACTIONS_COLUMN_SIZE,
) * 0.7,
) )
const selectedIds = table.getSelectedRowModel().rows.map((r) => r.id) const selectedIds = table.getSelectedRowModel().rows.map((r) => r.id)
const clear = () => setRowSelection({}) const clear = () => setRowSelection({})
@@ -284,7 +287,7 @@ export function PreviewSectionGrid<T extends object>({
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
tableLayout={{ headerSticky: false, dense: false, width: 'auto', rowsDraggable: Boolean(onReorder) }} tableLayout={{ headerSticky: false, dense: false, width: 'auto', rowsDraggable: Boolean(onReorder) }}
> >
<Frame spacing="sm" className={cn('w-full min-w-0', className)}> <Frame className={cn('[--frame-panel-header-py-adjust:4px] w-full min-w-0', className)}>
<FrameHeader className="flex-row flex-wrap items-center gap-x-3 gap-y-2"> <FrameHeader className="flex-row flex-wrap items-center gap-x-3 gap-y-2">
<div className="flex min-w-0 flex-1 basis-52 flex-col gap-0.5"> <div className="flex min-w-0 flex-1 basis-52 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle> <FrameTitle>{title}</FrameTitle>
@@ -293,9 +296,13 @@ export function PreviewSectionGrid<T extends object>({
{typeof tools === 'function' ? tools(selectedIds) : tools} {typeof tools === 'function' ? tools(selectedIds) : tools}
{card ? ( {card ? (
<Tabs value={cards ? 'cards' : 'table'} onValueChange={(v) => setCards(v === 'cards')} aria-label="Вид списка"> <Tabs value={cards ? 'cards' : 'table'} onValueChange={(v) => setCards(v === 'cards')} aria-label="Вид списка">
<TabsList> <TabsList className="h-9 gap-0.5 p-[3px]">
<TabsTrigger value="table">Таблица</TabsTrigger> <TabsTrigger value="table" className="h-7 px-2.5 text-[12.5px]">
<TabsTrigger value="cards">Карточки</TabsTrigger> Таблица
</TabsTrigger>
<TabsTrigger value="cards" className="h-7 px-2.5 text-[12.5px]">
Карточки
</TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
) : null} ) : null}
@@ -363,7 +370,6 @@ export function PreviewSectionGrid<T extends object>({
<b className="mr-1.5 font-semibold">Выбрано: {selectedIds.length}</b> <b className="mr-1.5 font-semibold">Выбрано: {selectedIds.length}</b>
<Button <Button
type="button" type="button"
size="sm"
variant="outline" variant="outline"
onClick={() => { onClick={() => {
if (bulk?.onRun) bulk.onRun(selectedIds) if (bulk?.onRun) bulk.onRun(selectedIds)
@@ -375,7 +381,6 @@ export function PreviewSectionGrid<T extends object>({
</Button> </Button>
<Button <Button
type="button" type="button"
size="sm"
variant="outline" variant="outline"
onClick={() => { onClick={() => {
if (bulk?.offRun) bulk.offRun(selectedIds) if (bulk?.offRun) bulk.offRun(selectedIds)
@@ -387,7 +392,6 @@ export function PreviewSectionGrid<T extends object>({
</Button> </Button>
<Button <Button
type="button" type="button"
size="sm"
variant="outline" variant="outline"
className="text-destructive" className="text-destructive"
onClick={() => { onClick={() => {
@@ -398,7 +402,7 @@ export function PreviewSectionGrid<T extends object>({
Удалить… Удалить…
</Button> </Button>
<span className="flex-1" /> <span className="flex-1" />
<Button type="button" size="sm" variant="outline" onClick={clear}> <Button type="button" variant="outline" onClick={clear}>
Снять выбор Снять выбор
</Button> </Button>
</div> </div>
+3 -3
View File
@@ -37,13 +37,13 @@ export function UnsavedBar() {
<b className="font-semibold">{pending.length}</b> · изменения ещё не записаны в портал <b className="font-semibold">{pending.length}</b> · изменения ещё не записаны в портал
</span> </span>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button type="button" size="sm" variant="outline" onClick={() => setDiffOpen(true)}> <Button type="button" variant="outline" onClick={() => setDiffOpen(true)}>
Просмотреть diff Просмотреть diff
</Button> </Button>
<Button type="button" size="sm" variant="outline" onClick={discardAll}> <Button type="button" variant="outline" onClick={discardAll}>
Отменить Отменить
</Button> </Button>
<Button type="button" size="sm" onClick={applyAll}> <Button type="button" onClick={applyAll}>
Сохранить Сохранить
</Button> </Button>
</div> </div>
+2 -2
View File
@@ -59,7 +59,7 @@ export function UsersGrid({ users, handlers, marks }: { users: AdminUser[]; hand
{ id: 'role', title: 'Роль', size: 130, sortValue: (u) => (u.is_admin ? 'a' : 'u'), cell: (u) => <span className="font-mono text-[12.5px]">{u.is_admin ? 'Админ' : 'Пользователь'}</span> }, { id: 'role', title: 'Роль', size: 130, sortValue: (u) => (u.is_admin ? 'a' : 'u'), cell: (u) => <span className="font-mono text-[12.5px]">{u.is_admin ? 'Админ' : 'Пользователь'}</span> },
{ id: 'apps', title: 'Приложения', size: 220, cell: (u) => <span className="block truncate font-mono text-[12.5px]">{appsSummary(u)}</span> }, { id: 'apps', title: 'Приложения', size: 220, cell: (u) => <span className="block truncate font-mono text-[12.5px]">{appsSummary(u)}</span> },
{ id: 'passkey', title: 'Passkey', size: 100, sortValue: (u) => u.passkey_count, cell: (u) => <span className="font-mono text-[12.5px]">{passkeyLabel(u.passkey_count)}</span> }, { id: 'passkey', title: 'Passkey', size: 100, sortValue: (u) => u.passkey_count, cell: (u) => <span className="font-mono text-[12.5px]">{passkeyLabel(u.passkey_count)}</span> },
{ id: 'activity', title: 'Активность · IP', size: 240, cell: (u) => <span className="font-mono text-[12.5px] whitespace-nowrap">{USER_META[u.id]?.activity ?? '—'}</span> }, { id: 'activity', title: 'Активность · IP', size: 240, cell: (u) => <span className="font-mono text-[12.5px]">{USER_META[u.id]?.activity ?? '—'}</span> },
], ],
[marks], [marks],
) )
@@ -108,7 +108,7 @@ export function UsersGrid({ users, handlers, marks }: { users: AdminUser[]; hand
onDelete: handlers.onBulkDelete, onDelete: handlers.onBulkDelete,
}} }}
tools={(ids) => ( tools={(ids) => (
<Button type="button" variant="outline" size="sm" onClick={() => handlers.onBulkResetPasskeys(ids.length > 0 ? ids : ['u-3'])}> <Button type="button" variant="outline" onClick={() => handlers.onBulkResetPasskeys(ids.length > 0 ? ids : ['u-3'])}>
Сбросить passkeys… Сбросить passkeys…
</Button> </Button>
)} )}