Expand README with architecture and setup details

This commit is contained in:
2026-07-08 09:58:26 +03:00
parent 81be7e186c
commit c5120669d2
109 changed files with 22311 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
import { MoreHorizontal } from 'lucide-react';
import { IconButton } from './IconButton';
export interface ActionMenuItem {
label: string;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
}
export interface ActionMenuProps {
open: boolean;
onOpenChange: (open: boolean) => void;
label: string;
items: ActionMenuItem[];
disabled?: boolean;
}
export function ActionMenu({
open,
onOpenChange,
label,
items,
disabled,
}: ActionMenuProps) {
return (
<div className="ui-action-menu">
<IconButton
label={label}
icon={<MoreHorizontal size={20} strokeWidth={2} />}
onClick={() => onOpenChange(!open)}
disabled={disabled}
aria-expanded={open}
/>
{open ? (
<div className="ui-action-menu-popover" role="menu">
{items.map((item) => (
<button
type="button"
role="menuitem"
className={item.danger ? 'is-danger' : ''}
onClick={item.onClick}
disabled={item.disabled}
key={item.label}
>
{item.label}
</button>
))}
</div>
) : null}
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
export type ButtonVariant = 'primary' | 'neutral' | 'add' | 'danger';
export type ButtonSize = 'sm' | 'md' | 'lg';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
loadingLabel?: string;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
}
export function Button({
variant = 'neutral',
size = 'md',
loading = false,
loadingLabel,
leftIcon,
rightIcon,
className,
children,
disabled,
...props
}: ButtonProps) {
const classes = [
'ui-button',
`ui-button--${variant}`,
`ui-button--${size}`,
loading ? 'is-loading' : '',
className ?? '',
].filter(Boolean).join(' ');
return (
<button
{...props}
className={classes}
disabled={disabled || loading}
>
{loading ? <span className="ui-button-spinner" aria-hidden="true" /> : leftIcon ? (
<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}
</button>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { InputHTMLAttributes, ReactNode } from 'react';
export interface FieldProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string | null;
hint?: string;
action?: ReactNode;
}
export function Field({
label,
error,
hint,
action,
className,
id,
...props
}: FieldProps) {
const inputId = id ?? `field-${label.toLowerCase().replace(/\s+/g, '-')}`;
const helpId = `${inputId}-help`;
return (
<label className={`ui-field ${className ?? ''}`.trim()} htmlFor={inputId}>
<span className="ui-field-label">{label}</span>
<div className="ui-field-row">
<input
{...props}
id={inputId}
aria-invalid={Boolean(error)}
aria-describedby={error || hint ? helpId : undefined}
/>
{action}
</div>
{error || hint ? <span id={helpId} className={`ui-field-help ${error ? 'is-error' : ''}`.trim()}>{error ?? hint}</span> : null}
</label>
);
}
+42
View File
@@ -0,0 +1,42 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
export type IconButtonVariant = 'neutral' | 'add' | 'danger';
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
label: string;
icon: ReactNode;
variant?: IconButtonVariant;
loading?: boolean;
}
export function IconButton({
label,
icon,
variant = 'neutral',
loading = false,
className,
disabled,
title,
...props
}: IconButtonProps) {
const classes = [
'ui-icon-button',
`ui-icon-button--${variant}`,
loading ? 'is-loading' : '',
className ?? '',
].filter(Boolean).join(' ');
return (
<button
{...props}
type={props.type ?? 'button'}
className={classes}
aria-label={label}
title={title ?? label}
disabled={disabled || loading}
>
{loading ? <span className="ui-button-spinner" aria-hidden="true" /> : icon}
</button>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { Button } from './Button';
export interface LogDockEntry {
id: string;
kind: 'success' | 'error' | 'info';
title: string;
text: string;
at: number;
}
export interface LogDockProps {
entries: LogDockEntry[];
activeEntry: LogDockEntry | null;
open: boolean;
onToggle: () => void;
formatTime: (timestamp: number) => string;
}
function isNativePreviewError(entry: LogDockEntry | null) {
if (!entry) return false;
return entry.text.includes("reading 'invoke'") || entry.text.includes('undefined (reading');
}
function displayEntry(entry: LogDockEntry | null) {
if (!entry) return null;
if (!isNativePreviewError(entry)) return entry;
return {
...entry,
title: 'Desktop-команды недоступны',
text: 'Запусти клиент через Tauri, чтобы управлять службами и применять конфиг.',
};
}
export function LogDock({
entries,
activeEntry,
open,
onToggle,
formatTime,
}: LogDockProps) {
const current = displayEntry(activeEntry);
return (
<footer className={`log-dock ${current?.kind ?? 'idle'}`} aria-live="polite">
<div className={`log-current ${current ? 'visible' : 'hidden'}`}>
{current ? (
<>
<strong>{current.title}</strong>
<span>{current.text}</span>
</>
) : (
<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>
{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}
</div>
</div>
);
}) : (
<div className="log-history-row">
<time>--:--:--</time>
<span>Событий пока нет.</span>
</div>
)}
</div>
) : null}
</footer>
);
}
+93
View File
@@ -0,0 +1,93 @@
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 interface ServicePrimaryAction {
label: string;
onClick: () => void;
variant?: ButtonVariant;
loading?: boolean;
loadingLabel?: string;
disabled?: boolean;
}
export interface ServiceControlRowProps {
state: ServiceControlState;
title: string;
detail: string;
primaryAction?: ServicePrimaryAction;
menu?: {
label: string;
open: boolean;
onOpenChange: (open: boolean) => void;
items: ActionMenuItem[];
disabled?: boolean;
};
visualState?: 'working' | 'settling' | null;
className?: string;
inlineActions?: ReactNode;
children?: ReactNode;
}
export function ServiceControlRow({
state,
title,
detail,
primaryAction,
menu,
visualState,
className,
inlineActions,
children,
}: ServiceControlRowProps) {
const classes = [
'ui-service-row',
`ui-service-row--${state}`,
visualState ? `ui-service-row--${visualState}` : '',
className ?? '',
].filter(Boolean).join(' ');
return (
<div className={classes}>
<span className="ui-service-border-glow" aria-hidden="true">
<span className="ui-service-border-glow-segment top" />
<span className="ui-service-border-glow-segment right" />
<span className="ui-service-border-glow-segment bottom" />
<span className="ui-service-border-glow-segment left" />
</span>
<span className="ui-service-dot" aria-hidden="true" />
<div className="ui-service-text">
<strong>{title}</strong>
<span>{detail}</span>
{inlineActions ? <div className="ui-service-inline-actions">{inlineActions}</div> : null}
</div>
<div className="ui-service-actions">
{primaryAction ? (
<Button
type="button"
variant={primaryAction.variant ?? 'neutral'}
onClick={primaryAction.onClick}
disabled={primaryAction.disabled}
loading={primaryAction.loading}
loadingLabel={primaryAction.loadingLabel}
>
{primaryAction.label}
</Button>
) : null}
{menu ? (
<ActionMenu
label={menu.label}
open={menu.open}
onOpenChange={menu.onOpenChange}
items={menu.items}
disabled={menu.disabled}
/>
) : null}
</div>
{children}
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
export type StatusPillTone = 'ok' | 'warning' | 'error' | 'checking' | 'muted';
export interface StatusPillProps {
tone?: StatusPillTone;
children: string;
}
export function StatusPill({ tone = 'muted', children }: StatusPillProps) {
return <span className={`ui-status-pill ui-status-pill--${tone}`}>{children}</span>;
}
+81
View File
@@ -0,0 +1,81 @@
import { useRef, type KeyboardEvent } from 'react';
export interface TabItem<T extends string> {
id: T;
label: string;
}
export interface TabsProps<T extends string> {
items: Array<TabItem<T>>;
activeId: T;
onChange: (id: T) => void;
ariaLabel: string;
}
export function Tabs<T extends string>({
items,
activeId,
onChange,
ariaLabel,
}: TabsProps<T>) {
const refs = useRef<Array<HTMLButtonElement | null>>([]);
function moveFocus(currentId: T, direction: 1 | -1) {
const currentIndex = items.findIndex((item) => item.id === currentId);
const nextIndex = (currentIndex + direction + items.length) % items.length;
const next = items[nextIndex];
if (!next) return;
onChange(next.id);
window.requestAnimationFrame(() => refs.current[nextIndex]?.focus());
}
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>, id: T) {
if (event.key === 'ArrowRight') {
event.preventDefault();
moveFocus(id, 1);
} else if (event.key === 'ArrowLeft') {
event.preventDefault();
moveFocus(id, -1);
} 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') {
event.preventDefault();
const last = items[items.length - 1];
if (!last) return;
onChange(last.id);
window.requestAnimationFrame(() => refs.current[items.length - 1]?.focus());
}
}
return (
<div className="ui-tabs" role="tablist" aria-label={ariaLabel}>
{items.map((item, index) => {
const active = item.id === activeId;
return (
<button
type="button"
role="tab"
id={`tab-${item.id}`}
aria-controls={`panel-${item.id}`}
aria-selected={active}
tabIndex={active ? 0 : -1}
className={`ui-tab ${active ? 'is-active' : ''}`.trim()}
key={item.id}
ref={(node) => {
refs.current[index] = node;
}}
onClick={() => onChange(item.id)}
onKeyDown={(event) => handleKeyDown(event, item.id)}
>
{item.label}
</button>
);
})}
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
export { ActionMenu } from './ActionMenu';
export type { ActionMenuItem } from './ActionMenu';
export { Button } from './Button';
export type { ButtonProps, ButtonSize, ButtonVariant } from './Button';
export { Field } from './Field';
export type { FieldProps } from './Field';
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';