Refactor application structure and simplify implementation
This commit is contained in:
+56
-49
@@ -1,4 +1,4 @@
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { IconButton } from './IconButton';
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
export interface ActionMenuItem {
|
||||
label: string;
|
||||
@@ -29,7 +29,7 @@ interface ActionMenuPosition {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
placement: 'top' | 'bottom';
|
||||
placement: "top" | "bottom";
|
||||
}
|
||||
|
||||
const MENU_WIDTH = 190;
|
||||
@@ -50,7 +50,7 @@ export function ActionMenu({
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: MENU_WIDTH,
|
||||
placement: 'bottom',
|
||||
placement: "bottom",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -65,22 +65,28 @@ export function ActionMenu({
|
||||
if (!trigger) return;
|
||||
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const width = Math.min(MENU_WIDTH, Math.max(180, window.innerWidth - VIEWPORT_MARGIN * 2));
|
||||
const width = Math.min(
|
||||
MENU_WIDTH,
|
||||
Math.max(180, window.innerWidth - VIEWPORT_MARGIN * 2),
|
||||
);
|
||||
const popoverHeight = popoverRef.current?.offsetHeight ?? 0;
|
||||
const left = Math.max(
|
||||
VIEWPORT_MARGIN,
|
||||
Math.min(rect.right - width, window.innerWidth - width - VIEWPORT_MARGIN),
|
||||
Math.min(
|
||||
rect.right - width,
|
||||
window.innerWidth - width - VIEWPORT_MARGIN,
|
||||
),
|
||||
);
|
||||
let top = rect.bottom + MENU_OFFSET;
|
||||
let placement: ActionMenuPosition['placement'] = 'bottom';
|
||||
let placement: ActionMenuPosition["placement"] = "bottom";
|
||||
|
||||
if (
|
||||
popoverHeight
|
||||
&& top + popoverHeight > window.innerHeight - VIEWPORT_MARGIN
|
||||
&& rect.top > popoverHeight + VIEWPORT_MARGIN + MENU_OFFSET
|
||||
popoverHeight &&
|
||||
top + popoverHeight > window.innerHeight - VIEWPORT_MARGIN &&
|
||||
rect.top > popoverHeight + VIEWPORT_MARGIN + MENU_OFFSET
|
||||
) {
|
||||
top = rect.top - popoverHeight - MENU_OFFSET;
|
||||
placement = 'top';
|
||||
placement = "top";
|
||||
}
|
||||
|
||||
const maxTop = popoverHeight
|
||||
@@ -97,13 +103,13 @@ export function ActionMenu({
|
||||
|
||||
updatePosition();
|
||||
const frame = window.requestAnimationFrame(updatePosition);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
window.addEventListener("resize", updatePosition);
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
@@ -118,16 +124,16 @@ export function ActionMenu({
|
||||
};
|
||||
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
if (event.key !== "Escape") return;
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', closeOnOutsidePointer);
|
||||
document.addEventListener('keydown', closeOnEscape);
|
||||
document.addEventListener("pointerdown", closeOnOutsidePointer);
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', closeOnOutsidePointer);
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
document.removeEventListener("pointerdown", closeOnOutsidePointer);
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [onOpenChange, open]);
|
||||
|
||||
@@ -148,34 +154,35 @@ export function ActionMenu({
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
/>
|
||||
{open && typeof document !== 'undefined' ? createPortal(
|
||||
<div
|
||||
className="ui-action-menu-popover"
|
||||
data-placement={position.placement}
|
||||
id={menuId}
|
||||
ref={popoverRef}
|
||||
role="menu"
|
||||
style={popoverStyle}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={item.danger ? 'is-danger' : ''}
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
item.onClick();
|
||||
}}
|
||||
disabled={item.disabled}
|
||||
key={item.label}
|
||||
{open && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<div
|
||||
className="ui-action-menu-popover"
|
||||
data-placement={position.placement}
|
||||
id={menuId}
|
||||
ref={popoverRef}
|
||||
role="menu"
|
||||
style={popoverStyle}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
) : null}
|
||||
{items.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={item.danger ? "is-danger" : ""}
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
item.onClick();
|
||||
}}
|
||||
disabled={item.disabled}
|
||||
key={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ export interface BusyRingProps {
|
||||
}
|
||||
|
||||
export function BusyRing({ className }: BusyRingProps) {
|
||||
const classes = ['ui-busy-ring', className ?? ''].filter(Boolean).join(' ');
|
||||
const classes = ["ui-busy-ring", className ?? ""].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<span className={classes} aria-hidden="true">
|
||||
|
||||
+23
-14
@@ -1,8 +1,8 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import { BusyRing } from './BusyRing';
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { BusyRing } from "./BusyRing";
|
||||
|
||||
export type ButtonVariant = 'primary' | 'neutral' | 'add' | 'danger';
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
export type ButtonVariant = "primary" | "neutral" | "add" | "danger";
|
||||
export type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
@@ -14,8 +14,8 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'neutral',
|
||||
size = 'md',
|
||||
variant = "neutral",
|
||||
size = "md",
|
||||
loading = false,
|
||||
loadingLabel,
|
||||
leftIcon,
|
||||
@@ -26,12 +26,14 @@ export function Button({
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const classes = [
|
||||
'ui-button',
|
||||
"ui-button",
|
||||
`ui-button--${variant}`,
|
||||
`ui-button--${size}`,
|
||||
loading ? 'is-loading' : '',
|
||||
className ?? '',
|
||||
].filter(Boolean).join(' ');
|
||||
loading ? "is-loading" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -42,11 +44,18 @@ export function Button({
|
||||
>
|
||||
{loading ? <BusyRing /> : null}
|
||||
{!loading && leftIcon ? (
|
||||
<span className="ui-button-icon" aria-hidden="true">{leftIcon}</span>
|
||||
<span className="ui-button-icon" aria-hidden="true">
|
||||
{leftIcon}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="ui-button-label">
|
||||
{loading && loadingLabel ? loadingLabel : children}
|
||||
</span>
|
||||
{!loading && rightIcon ? (
|
||||
<span className="ui-button-icon" aria-hidden="true">
|
||||
{rightIcon}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="ui-button-label">{loading && loadingLabel ? loadingLabel : children}</span>
|
||||
{!loading && rightIcon ? <span className="ui-button-icon" aria-hidden="true">{rightIcon}</span> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+45
-31
@@ -8,12 +8,15 @@ import {
|
||||
type CSSProperties,
|
||||
type MouseEvent,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export type DetailsPopoverAlign = 'start' | 'center' | 'end';
|
||||
export type DetailsPopoverAlign = "start" | "center" | "end";
|
||||
|
||||
export interface DetailsPopoverProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title'> {
|
||||
export interface DetailsPopoverProps extends Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
"title"
|
||||
> {
|
||||
details: string | string[];
|
||||
children: ReactNode;
|
||||
popoverLabel?: string;
|
||||
@@ -26,7 +29,7 @@ interface DetailsPopoverPosition {
|
||||
left: number;
|
||||
width: number;
|
||||
arrowLeft: number;
|
||||
placement: 'top' | 'bottom';
|
||||
placement: "top" | "bottom";
|
||||
}
|
||||
|
||||
const VIEWPORT_MARGIN = 12;
|
||||
@@ -35,8 +38,8 @@ export function DetailsPopover({
|
||||
details,
|
||||
children,
|
||||
className,
|
||||
popoverLabel = 'Детали',
|
||||
align = 'start',
|
||||
popoverLabel = "Детали",
|
||||
align = "start",
|
||||
maxWidth = 360,
|
||||
disabled,
|
||||
onClick,
|
||||
@@ -51,12 +54,14 @@ export function DetailsPopover({
|
||||
left: 0,
|
||||
width: Math.min(maxWidth, 360),
|
||||
arrowLeft: 24,
|
||||
placement: 'bottom',
|
||||
placement: "bottom",
|
||||
});
|
||||
const detailLines = Array.isArray(details)
|
||||
? details.filter(Boolean)
|
||||
: [details].filter(Boolean);
|
||||
const classes = ['ui-details-popover-trigger', className ?? ''].filter(Boolean).join(' ');
|
||||
const classes = ["ui-details-popover-trigger", className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled && open) setOpen(false);
|
||||
@@ -70,26 +75,35 @@ export function DetailsPopover({
|
||||
if (!trigger) return;
|
||||
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const width = Math.min(maxWidth, Math.max(220, window.innerWidth - VIEWPORT_MARGIN * 2));
|
||||
const width = Math.min(
|
||||
maxWidth,
|
||||
Math.max(220, window.innerWidth - VIEWPORT_MARGIN * 2),
|
||||
);
|
||||
let left = rect.left;
|
||||
if (align === 'center') left = rect.left + rect.width / 2 - width / 2;
|
||||
if (align === 'end') left = rect.right - width;
|
||||
if (align === "center") left = rect.left + rect.width / 2 - width / 2;
|
||||
if (align === "end") left = rect.right - width;
|
||||
|
||||
left = Math.max(VIEWPORT_MARGIN, Math.min(left, window.innerWidth - width - VIEWPORT_MARGIN));
|
||||
left = Math.max(
|
||||
VIEWPORT_MARGIN,
|
||||
Math.min(left, window.innerWidth - width - VIEWPORT_MARGIN),
|
||||
);
|
||||
|
||||
const popoverHeight = popoverRef.current?.offsetHeight ?? 0;
|
||||
let top = rect.bottom + 8;
|
||||
let placement: DetailsPopoverPosition['placement'] = 'bottom';
|
||||
let placement: DetailsPopoverPosition["placement"] = "bottom";
|
||||
|
||||
if (
|
||||
popoverHeight
|
||||
&& top + popoverHeight > window.innerHeight - VIEWPORT_MARGIN
|
||||
&& rect.top > popoverHeight + VIEWPORT_MARGIN + 8
|
||||
popoverHeight &&
|
||||
top + popoverHeight > window.innerHeight - VIEWPORT_MARGIN &&
|
||||
rect.top > popoverHeight + VIEWPORT_MARGIN + 8
|
||||
) {
|
||||
top = rect.top - popoverHeight - 8;
|
||||
placement = 'top';
|
||||
placement = "top";
|
||||
} else if (popoverHeight) {
|
||||
top = Math.min(top, window.innerHeight - popoverHeight - VIEWPORT_MARGIN);
|
||||
top = Math.min(
|
||||
top,
|
||||
window.innerHeight - popoverHeight - VIEWPORT_MARGIN,
|
||||
);
|
||||
}
|
||||
|
||||
const arrowLeft = Math.max(
|
||||
@@ -108,13 +122,13 @@ export function DetailsPopover({
|
||||
|
||||
updatePosition();
|
||||
const frame = window.requestAnimationFrame(updatePosition);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
window.addEventListener("resize", updatePosition);
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
};
|
||||
}, [align, maxWidth, open]);
|
||||
|
||||
@@ -129,17 +143,17 @@ export function DetailsPopover({
|
||||
};
|
||||
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
if (event.key !== "Escape") return;
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', closeOnOutsidePointer);
|
||||
document.addEventListener('keydown', closeOnEscape);
|
||||
document.addEventListener("pointerdown", closeOnOutsidePointer);
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', closeOnOutsidePointer);
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
document.removeEventListener("pointerdown", closeOnOutsidePointer);
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
@@ -152,7 +166,7 @@ export function DetailsPopover({
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
width: position.width,
|
||||
'--details-popover-arrow-left': `${position.arrowLeft}px`,
|
||||
"--details-popover-arrow-left": `${position.arrowLeft}px`,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
@@ -160,7 +174,7 @@ export function DetailsPopover({
|
||||
<button
|
||||
{...props}
|
||||
ref={triggerRef}
|
||||
type={props.type ?? 'button'}
|
||||
type={props.type ?? "button"}
|
||||
className={classes}
|
||||
aria-controls={open ? detailsId : undefined}
|
||||
aria-expanded={open}
|
||||
@@ -170,7 +184,7 @@ export function DetailsPopover({
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
{open && detailLines.length && typeof document !== 'undefined'
|
||||
{open && detailLines.length && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
|
||||
+11
-5
@@ -1,4 +1,4 @@
|
||||
import type { InputHTMLAttributes, ReactNode } from 'react';
|
||||
import type { InputHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export interface FieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string;
|
||||
@@ -16,11 +16,11 @@ export function Field({
|
||||
id,
|
||||
...props
|
||||
}: FieldProps) {
|
||||
const inputId = id ?? `field-${label.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
const inputId = id ?? `field-${label.toLowerCase().replace(/\s+/g, "-")}`;
|
||||
const helpId = `${inputId}-help`;
|
||||
|
||||
return (
|
||||
<label className={`ui-field ${className ?? ''}`.trim()} htmlFor={inputId}>
|
||||
<label className={`ui-field ${className ?? ""}`.trim()} htmlFor={inputId}>
|
||||
<span className="ui-field-label">{label}</span>
|
||||
<div className="ui-field-row">
|
||||
<input
|
||||
@@ -31,8 +31,14 @@ export function Field({
|
||||
/>
|
||||
{action}
|
||||
</div>
|
||||
{error || hint ? <span id={helpId} className={`ui-field-help ${error ? 'is-error' : ''}`.trim()}>{error ?? hint}</span> : null}
|
||||
{error || hint ? (
|
||||
<span
|
||||
id={helpId}
|
||||
className={`ui-field-help ${error ? "is-error" : ""}`.trim()}
|
||||
>
|
||||
{error ?? hint}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export interface HoverDetailsProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
details: string | string[];
|
||||
@@ -12,9 +12,11 @@ export function HoverDetails({
|
||||
...props
|
||||
}: HoverDetailsProps) {
|
||||
const detailText = Array.isArray(details)
|
||||
? details.filter(Boolean).join('\n')
|
||||
? details.filter(Boolean).join("\n")
|
||||
: details;
|
||||
const classes = ['ui-hover-details', className ?? ''].filter(Boolean).join(' ');
|
||||
const classes = ["ui-hover-details", className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<span
|
||||
|
||||
+16
-12
@@ -1,9 +1,12 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import { BusyRing } from './BusyRing';
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { BusyRing } from "./BusyRing";
|
||||
|
||||
export type IconButtonVariant = 'neutral' | 'add' | 'danger';
|
||||
export type IconButtonVariant = "neutral" | "add" | "danger";
|
||||
|
||||
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title'> {
|
||||
export interface IconButtonProps extends Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
"title"
|
||||
> {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
variant?: IconButtonVariant;
|
||||
@@ -14,25 +17,27 @@ export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonEle
|
||||
export function IconButton({
|
||||
label,
|
||||
icon,
|
||||
variant = 'neutral',
|
||||
variant = "neutral",
|
||||
loading = false,
|
||||
tooltip,
|
||||
className,
|
||||
disabled,
|
||||
...props
|
||||
}: IconButtonProps) {
|
||||
const tooltipText = tooltip === '' ? undefined : tooltip ?? label;
|
||||
const tooltipText = tooltip === "" ? undefined : (tooltip ?? label);
|
||||
const classes = [
|
||||
'ui-icon-button',
|
||||
"ui-icon-button",
|
||||
`ui-icon-button--${variant}`,
|
||||
loading ? 'is-loading' : '',
|
||||
className ?? '',
|
||||
].filter(Boolean).join(' ');
|
||||
loading ? "is-loading" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
type={props.type ?? 'button'}
|
||||
type={props.type ?? "button"}
|
||||
className={classes}
|
||||
aria-label={label}
|
||||
data-tooltip={tooltipText}
|
||||
@@ -44,4 +49,3 @@ export function IconButton({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+40
-21
@@ -1,8 +1,8 @@
|
||||
import { Button } from './Button';
|
||||
import { Button } from "./Button";
|
||||
|
||||
export interface LogDockEntry {
|
||||
id: string;
|
||||
kind: 'success' | 'error' | 'info';
|
||||
kind: "success" | "error" | "info";
|
||||
title: string;
|
||||
text: string;
|
||||
at: number;
|
||||
@@ -18,7 +18,10 @@ export interface LogDockProps {
|
||||
|
||||
function isNativePreviewError(entry: LogDockEntry | null) {
|
||||
if (!entry) return false;
|
||||
return entry.text.includes("reading 'invoke'") || entry.text.includes('undefined (reading');
|
||||
return (
|
||||
entry.text.includes("reading 'invoke'") ||
|
||||
entry.text.includes("undefined (reading")
|
||||
);
|
||||
}
|
||||
|
||||
function displayEntry(entry: LogDockEntry | null) {
|
||||
@@ -26,8 +29,8 @@ function displayEntry(entry: LogDockEntry | null) {
|
||||
if (!isNativePreviewError(entry)) return entry;
|
||||
return {
|
||||
...entry,
|
||||
title: 'Desktop-команды недоступны',
|
||||
text: 'Запусти клиент через Tauri, чтобы управлять службами и применять конфиг.',
|
||||
title: "Desktop-команды недоступны",
|
||||
text: "Запусти клиент через Tauri, чтобы управлять службами и применять конфиг.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,8 +44,11 @@ export function LogDock({
|
||||
const current = displayEntry(activeEntry);
|
||||
|
||||
return (
|
||||
<footer className={`log-dock ${current?.kind ?? 'idle'}`} aria-live="polite">
|
||||
<div className={`log-current ${current ? 'visible' : 'hidden'}`}>
|
||||
<footer
|
||||
className={`log-dock ${current?.kind ?? "idle"}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className={`log-current ${current ? "visible" : "hidden"}`}>
|
||||
{current ? (
|
||||
<>
|
||||
<strong>{current.title}</strong>
|
||||
@@ -52,24 +58,37 @@ export function LogDock({
|
||||
<span className="log-muted">Журнал событий</span>
|
||||
)}
|
||||
</div>
|
||||
<Button type="button" variant="neutral" size="sm" className="log-toggle" onClick={onToggle}>
|
||||
{open ? 'Скрыть' : 'Посмотреть'} <span className="log-count">{entries.length}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
size="sm"
|
||||
className="log-toggle"
|
||||
onClick={onToggle}
|
||||
>
|
||||
{open ? "Скрыть" : "Посмотреть"}{" "}
|
||||
<span className="log-count">{entries.length}</span>
|
||||
</Button>
|
||||
{open ? (
|
||||
<div className="log-history">
|
||||
{entries.length ? entries.map((entry) => {
|
||||
const friendly = displayEntry(entry);
|
||||
return (
|
||||
<div className={`log-history-row ${entry.kind}`} key={entry.id}>
|
||||
<time>{formatTime(entry.at)}</time>
|
||||
<div>
|
||||
<strong>{friendly?.title ?? entry.title}</strong>
|
||||
<span>{friendly?.text ?? entry.text}</span>
|
||||
{isNativePreviewError(entry) ? <span className="log-raw-detail">Детали: {entry.text}</span> : null}
|
||||
{entries.length ? (
|
||||
entries.map((entry) => {
|
||||
const friendly = displayEntry(entry);
|
||||
return (
|
||||
<div className={`log-history-row ${entry.kind}`} key={entry.id}>
|
||||
<time>{formatTime(entry.at)}</time>
|
||||
<div>
|
||||
<strong>{friendly?.title ?? entry.title}</strong>
|
||||
<span>{friendly?.text ?? entry.text}</span>
|
||||
{isNativePreviewError(entry) ? (
|
||||
<span className="log-raw-detail">
|
||||
Детали: {entry.text}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="log-history-row">
|
||||
<time>--:--:--</time>
|
||||
<span>Событий пока нет.</span>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button, type ButtonVariant } from './Button';
|
||||
import { ActionMenu, type ActionMenuItem } from './ActionMenu';
|
||||
import type { ReactNode } from "react";
|
||||
import { Button, type ButtonVariant } from "./Button";
|
||||
import { ActionMenu, type ActionMenuItem } from "./ActionMenu";
|
||||
|
||||
export type ServiceControlState = 'checking' | 'missing' | 'installed' | 'running' | 'stopped' | 'error';
|
||||
export type ServiceControlState =
|
||||
"checking" | "missing" | "installed" | "running" | "stopped" | "error";
|
||||
|
||||
export interface ServicePrimaryAction {
|
||||
label: string;
|
||||
@@ -25,7 +26,7 @@ export interface ServiceControlRowProps {
|
||||
items: ActionMenuItem[];
|
||||
disabled?: boolean;
|
||||
};
|
||||
visualState?: 'working' | 'settling' | null;
|
||||
visualState?: "working" | "settling" | null;
|
||||
className?: string;
|
||||
inlineActions?: ReactNode;
|
||||
children?: ReactNode;
|
||||
@@ -43,11 +44,13 @@ export function ServiceControlRow({
|
||||
children,
|
||||
}: ServiceControlRowProps) {
|
||||
const classes = [
|
||||
'ui-service-row',
|
||||
"ui-service-row",
|
||||
`ui-service-row--${state}`,
|
||||
visualState ? `ui-service-row--${visualState}` : '',
|
||||
className ?? '',
|
||||
].filter(Boolean).join(' ');
|
||||
visualState ? `ui-service-row--${visualState}` : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
@@ -61,7 +64,9 @@ export function ServiceControlRow({
|
||||
<div className="ui-service-text">
|
||||
<div className="ui-service-title-line">
|
||||
<strong>{title}</strong>
|
||||
{inlineActions ? <div className="ui-service-inline-actions">{inlineActions}</div> : null}
|
||||
{inlineActions ? (
|
||||
<div className="ui-service-inline-actions">{inlineActions}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span>{detail}</span>
|
||||
</div>
|
||||
@@ -69,7 +74,7 @@ export function ServiceControlRow({
|
||||
{primaryAction ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant={primaryAction.variant ?? 'neutral'}
|
||||
variant={primaryAction.variant ?? "neutral"}
|
||||
onClick={primaryAction.onClick}
|
||||
disabled={primaryAction.disabled}
|
||||
loading={primaryAction.loading}
|
||||
@@ -92,4 +97,3 @@ export function ServiceControlRow({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import { BusyRing } from './BusyRing';
|
||||
import { BusyRing } from "./BusyRing";
|
||||
|
||||
export type StatusPillTone = 'ok' | 'warning' | 'error' | 'checking' | 'muted';
|
||||
export type StatusPillTone = "ok" | "warning" | "error" | "checking" | "muted";
|
||||
|
||||
export interface StatusPillProps {
|
||||
tone?: StatusPillTone;
|
||||
children: string;
|
||||
}
|
||||
|
||||
export function StatusPill({ tone = 'muted', children }: StatusPillProps) {
|
||||
export function StatusPill({ tone = "muted", children }: StatusPillProps) {
|
||||
return (
|
||||
<span
|
||||
className={`ui-status-pill ui-status-pill--${tone}`}
|
||||
aria-busy={tone === 'checking' || undefined}
|
||||
aria-busy={tone === "checking" || undefined}
|
||||
>
|
||||
{tone === 'checking' ? <BusyRing /> : null}
|
||||
{tone === "checking" ? <BusyRing /> : null}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+9
-8
@@ -1,4 +1,4 @@
|
||||
import { useRef, type KeyboardEvent } from 'react';
|
||||
import { useRef, type KeyboardEvent } from "react";
|
||||
|
||||
export interface TabItem<T extends string> {
|
||||
id: T;
|
||||
@@ -30,24 +30,26 @@ export function Tabs<T extends string>({
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>, id: T) {
|
||||
if (event.key === 'ArrowRight') {
|
||||
if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
moveFocus(id, 1);
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
} else if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
moveFocus(id, -1);
|
||||
} else if (event.key === 'Home') {
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
const first = items[0];
|
||||
if (!first) return;
|
||||
onChange(first.id);
|
||||
window.requestAnimationFrame(() => refs.current[0]?.focus());
|
||||
} else if (event.key === 'End') {
|
||||
} else if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
const last = items[items.length - 1];
|
||||
if (!last) return;
|
||||
onChange(last.id);
|
||||
window.requestAnimationFrame(() => refs.current[items.length - 1]?.focus());
|
||||
window.requestAnimationFrame(() =>
|
||||
refs.current[items.length - 1]?.focus(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +65,7 @@ export function Tabs<T extends string>({
|
||||
aria-controls={`panel-${item.id}`}
|
||||
aria-selected={active}
|
||||
tabIndex={active ? 0 : -1}
|
||||
className={`ui-tab ${active ? 'is-active' : ''}`.trim()}
|
||||
className={`ui-tab ${active ? "is-active" : ""}`.trim()}
|
||||
key={item.id}
|
||||
ref={(node) => {
|
||||
refs.current[index] = node;
|
||||
@@ -78,4 +80,3 @@ export function Tabs<T extends string>({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+28
-23
@@ -1,23 +1,28 @@
|
||||
export { ActionMenu } from './ActionMenu';
|
||||
export type { ActionMenuItem } from './ActionMenu';
|
||||
export { BusyRing } from './BusyRing';
|
||||
export type { BusyRingProps } from './BusyRing';
|
||||
export { Button } from './Button';
|
||||
export type { ButtonProps, ButtonSize, ButtonVariant } from './Button';
|
||||
export { DetailsPopover } from './DetailsPopover';
|
||||
export type { DetailsPopoverAlign, DetailsPopoverProps } from './DetailsPopover';
|
||||
export { Field } from './Field';
|
||||
export type { FieldProps } from './Field';
|
||||
export { HoverDetails } from './HoverDetails';
|
||||
export type { HoverDetailsProps } from './HoverDetails';
|
||||
export { IconButton } from './IconButton';
|
||||
export type { IconButtonProps, IconButtonVariant } from './IconButton';
|
||||
export { LogDock } from './LogDock';
|
||||
export type { LogDockEntry, LogDockProps } from './LogDock';
|
||||
export { ServiceControlRow } from './ServiceControlRow';
|
||||
export type { ServiceControlRowProps, ServiceControlState } from './ServiceControlRow';
|
||||
export { StatusPill } from './StatusPill';
|
||||
export type { StatusPillProps, StatusPillTone } from './StatusPill';
|
||||
export { Tabs } from './Tabs';
|
||||
export type { TabItem, TabsProps } from './Tabs';
|
||||
|
||||
export { ActionMenu } from "./ActionMenu";
|
||||
export type { ActionMenuItem } from "./ActionMenu";
|
||||
export { BusyRing } from "./BusyRing";
|
||||
export type { BusyRingProps } from "./BusyRing";
|
||||
export { Button } from "./Button";
|
||||
export type { ButtonProps, ButtonSize, ButtonVariant } from "./Button";
|
||||
export { DetailsPopover } from "./DetailsPopover";
|
||||
export type {
|
||||
DetailsPopoverAlign,
|
||||
DetailsPopoverProps,
|
||||
} from "./DetailsPopover";
|
||||
export { Field } from "./Field";
|
||||
export type { FieldProps } from "./Field";
|
||||
export { HoverDetails } from "./HoverDetails";
|
||||
export type { HoverDetailsProps } from "./HoverDetails";
|
||||
export { IconButton } from "./IconButton";
|
||||
export type { IconButtonProps, IconButtonVariant } from "./IconButton";
|
||||
export { LogDock } from "./LogDock";
|
||||
export type { LogDockEntry, LogDockProps } from "./LogDock";
|
||||
export { ServiceControlRow } from "./ServiceControlRow";
|
||||
export type {
|
||||
ServiceControlRowProps,
|
||||
ServiceControlState,
|
||||
} from "./ServiceControlRow";
|
||||
export { StatusPill } from "./StatusPill";
|
||||
export type { StatusPillProps, StatusPillTone } from "./StatusPill";
|
||||
export { Tabs } from "./Tabs";
|
||||
export type { TabItem, TabsProps } from "./Tabs";
|
||||
|
||||
Reference in New Issue
Block a user