9ac01d217f
Бросок синхронизирован на пульте и презентации; d100 как десятки; оверлей скрывается только кнопкой «Убрать». Co-authored-by: Cursor <cursoragent@cursor.com>
105 lines
3.3 KiB
TypeScript
105 lines
3.3 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
||
|
||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||
import { DICE_SIDES, type DiceSides } from '../../shared/types/diceRoll';
|
||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||
import { getDndApi } from '../shared/dndApi';
|
||
import { useDiceRollState } from '../shared/dice/useDiceRollState';
|
||
import { Button } from '../shared/ui/controls';
|
||
|
||
import styles from './DiceApp.module.css';
|
||
|
||
export function DiceApp() {
|
||
const { t } = useEditorI18n();
|
||
const api = getDndApi();
|
||
const [state, diceApi] = useDiceRollState();
|
||
const [sides, setSides] = useState<DiceSides>(20);
|
||
const [count, setCount] = useState(1);
|
||
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') {
|
||
void api.invoke(ipcChannels.windows.closeDice, {});
|
||
}
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [api]);
|
||
|
||
return (
|
||
<div className={styles.page} data-testid="dice-app">
|
||
<div className={styles.sectionLabel}>{t('dice.sides')}</div>
|
||
<div className={styles.sidesGrid}>
|
||
{DICE_SIDES.map((s) => (
|
||
<Button
|
||
key={s}
|
||
variant={sides === s ? 'primary' : 'ghost'}
|
||
onClick={() => setSides(s)}
|
||
data-testid={`dice-side-${s}`}
|
||
>
|
||
d{s}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
|
||
<div className={styles.sectionLabel}>{t('dice.count')}</div>
|
||
<div className={styles.countRow}>
|
||
<Button
|
||
variant="ghost"
|
||
onClick={() => setCount((c) => Math.max(1, c - 1))}
|
||
ariaLabel={t('dice.dec')}
|
||
>
|
||
−
|
||
</Button>
|
||
<div className={styles.countValue} data-testid="dice-count">
|
||
{count}
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
onClick={() => setCount((c) => Math.min(20, c + 1))}
|
||
ariaLabel={t('dice.inc')}
|
||
>
|
||
+
|
||
</Button>
|
||
</div>
|
||
|
||
<div className={styles.actions}>
|
||
<Button
|
||
variant="primary"
|
||
onClick={() => void diceApi.dispatch({ kind: 'roll', count, sides })}
|
||
data-testid="dice-roll"
|
||
>
|
||
{t('dice.roll')}
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
disabled={!state?.values.length && !state?.active}
|
||
onClick={() => void diceApi.dispatch({ kind: 'clear' })}
|
||
>
|
||
{t('dice.clear')}
|
||
</Button>
|
||
</div>
|
||
|
||
{state && state.values.length > 0 ? (
|
||
<div className={styles.lastResult} data-testid="dice-last-result">
|
||
<div className={styles.lastLabel}>{t('dice.result')}</div>
|
||
<div className={styles.lastNotation}>{state.notation}</div>
|
||
<div className={styles.valueChips} aria-label={t('dice.result')}>
|
||
{state.values.map((v, i) => (
|
||
<span key={`${state.revision}-${i}`} className={styles.valueChip}>
|
||
{v}
|
||
</span>
|
||
))}
|
||
</div>
|
||
<div className={styles.lastTotal}>
|
||
{t('dice.total')}: <strong>{state.total}</strong>
|
||
</div>
|
||
{state.active ? <div className={styles.rollingHint}>{t('dice.rolling')}</div> : null}
|
||
</div>
|
||
) : (
|
||
<div className={styles.emptyResult}>{t('dice.noResult')}</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|