feat(control): таймер сессии поверх сцены
Отдельное окно countdown с «Показать игрокам»; на превью — move/resize/rotate, на презентации тот же layout. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
.page {
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.clock {
|
||||
font-size: 48px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: center;
|
||||
padding: 16px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-3);
|
||||
}
|
||||
|
||||
.clock[data-expired='1'] {
|
||||
border-color: rgba(229, 72, 77, 0.85);
|
||||
color: #ffb4b4;
|
||||
}
|
||||
|
||||
.presets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.presets > * {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.presets button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions > * {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.checkRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: auto;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text2);
|
||||
line-height: 1.35;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import {
|
||||
DEFAULT_SESSION_TIMER_DURATION_MS,
|
||||
formatSessionTimerMs,
|
||||
getSessionTimerRemainingMs,
|
||||
parseSessionTimerDurationInput,
|
||||
} from '../../shared/types/sessionTimer';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { useSessionTimerState } from '../shared/sessionTimer/useSessionTimerState';
|
||||
import { Button, Input } from '../shared/ui/controls';
|
||||
|
||||
import styles from './TimerApp.module.css';
|
||||
|
||||
const PRESETS_MS = [60_000, 5 * 60_000, 10 * 60_000] as const;
|
||||
|
||||
function durationInputFromMs(ms: number): string {
|
||||
return formatSessionTimerMs(ms);
|
||||
}
|
||||
|
||||
export function TimerApp() {
|
||||
const { t } = useEditorI18n();
|
||||
const api = getDndApi();
|
||||
const [state, timerApi] = useSessionTimerState();
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [durationDraft, setDurationDraft] = useState(durationInputFromMs(DEFAULT_SESSION_TIMER_DURATION_MS));
|
||||
|
||||
useEffect(() => {
|
||||
if (!state || state.running) return;
|
||||
setDurationDraft(durationInputFromMs(state.durationMs));
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state?.running) return;
|
||||
const id = window.setInterval(() => setNow(Date.now()), 200);
|
||||
return () => window.clearInterval(id);
|
||||
}, [state?.running]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
void api.invoke(ipcChannels.windows.closeTimer, {});
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [api]);
|
||||
|
||||
const remainingMs = useMemo(() => {
|
||||
if (!state) return DEFAULT_SESSION_TIMER_DURATION_MS;
|
||||
return getSessionTimerRemainingMs(state, now);
|
||||
}, [now, state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state?.running) return;
|
||||
if (remainingMs > 0) return;
|
||||
void timerApi.dispatch({ kind: 'pause' });
|
||||
}, [remainingMs, state?.running, timerApi]);
|
||||
|
||||
if (!state) {
|
||||
return <div className={styles.page} data-testid="timer-app" />;
|
||||
}
|
||||
|
||||
const applyDurationDraft = async () => {
|
||||
const parsed = parseSessionTimerDurationInput(durationDraft);
|
||||
if (parsed == null) {
|
||||
setDurationDraft(durationInputFromMs(state.durationMs));
|
||||
return;
|
||||
}
|
||||
await timerApi.dispatch({ kind: 'setDuration', durationMs: parsed });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page} data-testid="timer-app">
|
||||
<div className={styles.clock} data-expired={remainingMs <= 0 ? '1' : '0'}>
|
||||
{formatSessionTimerMs(remainingMs)}
|
||||
</div>
|
||||
|
||||
<div className={styles.presets}>
|
||||
{PRESETS_MS.map((ms) => (
|
||||
<Button
|
||||
key={ms}
|
||||
variant="ghost"
|
||||
disabled={state.running}
|
||||
onClick={() => {
|
||||
setDurationDraft(durationInputFromMs(ms));
|
||||
void timerApi.dispatch({ kind: 'setDuration', durationMs: ms });
|
||||
}}
|
||||
>
|
||||
{formatSessionTimerMs(ms)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span className={styles.fieldLabel}>{t('sessionTimer.duration')}</span>
|
||||
<Input
|
||||
value={durationDraft}
|
||||
onChange={setDurationDraft}
|
||||
onBlur={() => void applyDurationDraft()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void applyDurationDraft();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className={styles.actions}>
|
||||
{state.running ? (
|
||||
<Button variant="primary" onClick={() => void timerApi.dispatch({ kind: 'pause' })}>
|
||||
{t('sessionTimer.pause')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" onClick={() => void timerApi.dispatch({ kind: 'start' })}>
|
||||
{t('sessionTimer.start')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" onClick={() => void timerApi.dispatch({ kind: 'reset' })}>
|
||||
{t('sessionTimer.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<label className={styles.checkRow}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state.visibleToPlayers}
|
||||
onChange={(e) =>
|
||||
void timerApi.dispatch({ kind: 'setVisibleToPlayers', visible: e.target.checked })
|
||||
}
|
||||
/>
|
||||
<span>{t('sessionTimer.showToPlayers')}</span>
|
||||
</label>
|
||||
|
||||
<div className={styles.hint}>{t('sessionTimer.positionHint')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||
|
||||
import { TimerApp } from './TimerApp';
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (!rootEl) {
|
||||
throw new Error('Missing #root element');
|
||||
}
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<WindowErrorBoundary title="Timer">
|
||||
<EditorI18nProvider>
|
||||
<TimerApp />
|
||||
</EditorI18nProvider>
|
||||
</WindowErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
Reference in New Issue
Block a user