ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 50s
ci / ts / ts (packages + web) (push) Successful in 43s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 27s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m22s
- core: AuthSession keeps the access token in memory, refreshes it once for all parallel requests (single-flight) and retries them; refresh races between tabs are retried, reuse signs out; ApiError carries problem+json codes and field errors - web: TanStack Router with guards — guests go to /login, the bootstrap admin to /setup/admin, temporary passwords to /setup/password, admin pages by role - login, setup screens, Classic shell (top bar, role-based sidebar, empty player), settings: name, theme, language, password, devices - users admin: search, role filter, create with a one-time temporary password, reset, block, role change, delete as the policy allows; audit log for admins - ru/en strings for auth, admin and error codes; language and theme are saved in the profile Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
283 lines
8.4 KiB
TypeScript
283 lines
8.4 KiB
TypeScript
import {
|
|
useEffect,
|
|
useId,
|
|
useRef,
|
|
useState,
|
|
type ButtonHTMLAttributes,
|
|
type InputHTMLAttributes,
|
|
type KeyboardEvent as ReactKeyboardEvent,
|
|
type ReactNode,
|
|
} from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { closeIcon, visibilityIcon, visibilityOffIcon } from './icons';
|
|
import s from './ui.module.css';
|
|
|
|
const cx = (...c: (string | false | null | undefined)[]) => c.filter(Boolean).join(' ');
|
|
|
|
/** Иконка-маска: цвет берётся из currentColor. */
|
|
export function Icon({ src, size = 20, className }: { src: string; size?: number; className?: string }) {
|
|
return (
|
|
<span
|
|
aria-hidden
|
|
className={cx(s.icon, className)}
|
|
style={{ width: size, height: size, maskImage: `url("${src}")`, WebkitMaskImage: `url("${src}")` }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function Logo({ size = 28 }: { size?: number }) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<span className={s.logo} style={{ fontSize: size * 0.68 }}>
|
|
<span className={s.logoDot} style={{ width: size, height: size }} aria-hidden />
|
|
{t('app.name')}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
|
icon?: string;
|
|
busy?: boolean;
|
|
};
|
|
|
|
export function Button({ variant = 'secondary', icon, busy, className, children, disabled, ...rest }: ButtonProps) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
{...rest}
|
|
disabled={disabled || busy}
|
|
aria-busy={busy || undefined}
|
|
className={cx(s.button, s[variant], className)}
|
|
>
|
|
{icon && <Icon src={icon} size={18} />}
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
type FieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
|
|
label: string;
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
error?: string;
|
|
hint?: string;
|
|
mono?: boolean;
|
|
};
|
|
|
|
/** Поле ввода с подписью, ошибкой и (для паролей) кнопкой «показать». */
|
|
export function Field({ label, value, onChange, error, hint, mono, type = 'text', className, ...rest }: FieldProps) {
|
|
const { t } = useTranslation();
|
|
const id = useId();
|
|
const [shown, setShown] = useState(false);
|
|
const isPassword = type === 'password';
|
|
const note = error ?? hint;
|
|
return (
|
|
<div className={cx(s.field, className)}>
|
|
<label htmlFor={id} className={s.label}>
|
|
{label}
|
|
</label>
|
|
<div className={s.inputWrap}>
|
|
<input
|
|
id={id}
|
|
type={isPassword && shown ? 'text' : type}
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
aria-invalid={error ? true : undefined}
|
|
aria-describedby={note ? `${id}-note` : undefined}
|
|
className={cx(s.input, mono && s.mono, error && s.invalid)}
|
|
{...rest}
|
|
/>
|
|
{isPassword && (
|
|
<button
|
|
type="button"
|
|
className={s.reveal}
|
|
onClick={() => setShown((v) => !v)}
|
|
aria-label={shown ? t('form.hide_password') : t('form.show_password')}
|
|
>
|
|
<Icon src={shown ? visibilityOffIcon : visibilityIcon} size={18} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{note && (
|
|
<div id={`${id}-note`} className={error ? s.error : s.hint} role={error ? 'alert' : undefined}>
|
|
{note}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Сегментированный переключатель. */
|
|
export function Segmented<T extends string>({
|
|
label,
|
|
value,
|
|
options,
|
|
onChange,
|
|
}: {
|
|
label: string;
|
|
value: T;
|
|
options: { value: T; label: string }[];
|
|
onChange: (v: T) => void;
|
|
}) {
|
|
return (
|
|
<div className={s.segmented} role="group" aria-label={label}>
|
|
{options.map((o) => (
|
|
<button key={o.value} type="button" aria-pressed={value === o.value} onClick={() => onChange(o.value)}>
|
|
{o.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function FormError({ children }: { children?: ReactNode }) {
|
|
if (!children) return null;
|
|
return (
|
|
<div className={s.formError} role="alert">
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Модальный диалог: стеклянная карточка поверх подложки, Esc и клик по подложке закрывают. */
|
|
export function Dialog({
|
|
title,
|
|
onClose,
|
|
children,
|
|
footer,
|
|
}: {
|
|
title: string;
|
|
onClose: () => void;
|
|
children: ReactNode;
|
|
footer?: ReactNode;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const titleId = useId();
|
|
useEffect(() => {
|
|
const prev = document.activeElement as HTMLElement | null;
|
|
const first = ref.current?.querySelector<HTMLElement>('input, select, button:not([data-close])');
|
|
(first ?? ref.current)?.focus();
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose();
|
|
};
|
|
document.addEventListener('keydown', onKey);
|
|
return () => {
|
|
document.removeEventListener('keydown', onKey);
|
|
prev?.focus();
|
|
};
|
|
}, [onClose]);
|
|
return (
|
|
<div className={s.overlay} onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
|
<div ref={ref} className={s.dialog} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}>
|
|
<div className={s.dialogHead}>
|
|
<h2 id={titleId}>{title}</h2>
|
|
<button type="button" data-close className={s.iconButton} onClick={onClose} aria-label={t('form.close')}>
|
|
<Icon src={closeIcon} />
|
|
</button>
|
|
</div>
|
|
<div className={s.dialogBody}>{children}</div>
|
|
{footer && <div className={s.dialogFoot}>{footer}</div>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export type MenuItem = { label: string; icon?: string; danger?: boolean; onSelect: () => void };
|
|
|
|
/** Выпадающее меню: открывается кнопкой-триггером, закрывается кликом снаружи и Esc. */
|
|
export function Menu({
|
|
label,
|
|
trigger,
|
|
items,
|
|
header,
|
|
align = 'end',
|
|
triggerClassName,
|
|
}: {
|
|
label: string;
|
|
trigger: ReactNode;
|
|
items: MenuItem[];
|
|
header?: ReactNode;
|
|
align?: 'start' | 'end';
|
|
triggerClassName?: string;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onDown = (e: MouseEvent) => {
|
|
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
|
};
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') setOpen(false);
|
|
};
|
|
document.addEventListener('mousedown', onDown);
|
|
document.addEventListener('keydown', onKey);
|
|
ref.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
|
|
return () => {
|
|
document.removeEventListener('mousedown', onDown);
|
|
document.removeEventListener('keydown', onKey);
|
|
};
|
|
}, [open]);
|
|
const onMenuKey = (e: ReactKeyboardEvent) => {
|
|
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
|
e.preventDefault();
|
|
const els = [...(ref.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])];
|
|
const i = els.indexOf(document.activeElement as HTMLElement);
|
|
const next = e.key === 'ArrowDown' ? (i + 1) % els.length : (i - 1 + els.length) % els.length;
|
|
els[next]?.focus();
|
|
};
|
|
return (
|
|
<div className={s.menuWrap} ref={ref}>
|
|
<button
|
|
type="button"
|
|
className={triggerClassName ?? s.iconButton}
|
|
aria-label={label}
|
|
aria-haspopup="menu"
|
|
aria-expanded={open}
|
|
onClick={() => setOpen((v) => !v)}
|
|
>
|
|
{trigger}
|
|
</button>
|
|
{open && (
|
|
<div className={cx(s.menu, align === 'start' && s.menuStart)} role="menu" aria-label={label} onKeyDown={onMenuKey}>
|
|
{header}
|
|
{items.map((it) => (
|
|
<button
|
|
key={it.label}
|
|
type="button"
|
|
role="menuitem"
|
|
className={cx(s.menuItem, it.danger && s.menuDanger)}
|
|
onClick={() => {
|
|
setOpen(false);
|
|
it.onSelect();
|
|
}}
|
|
>
|
|
{it.icon && <Icon src={it.icon} size={18} />}
|
|
{it.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Avatar({ name, size = 32 }: { name: string; size?: number }) {
|
|
const initials = name
|
|
.split(/[\s._-]+/)
|
|
.filter(Boolean)
|
|
.slice(0, 2)
|
|
.map((p) => p[0]!.toUpperCase())
|
|
.join('');
|
|
return (
|
|
<span className={s.avatar} style={{ width: size, height: size, fontSize: size * 0.38 }} aria-hidden>
|
|
{initials || '?'}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export { cx };
|