feat: окно инструкций, свёрнутая история и обновление спонсоров
Инструкции вынесены в отдельное окно Electron; токены игроков не пропадают при смене сцены; предпросмотр и плейлисты адаптированы; история свёрнута по умолчанию. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
.page {
|
||||
height: 100vh;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(160px, 220px);
|
||||
gap: 14px;
|
||||
background: var(--color-surface-elevated);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--bg0);
|
||||
}
|
||||
|
||||
.contentTitle {
|
||||
font-weight: 800;
|
||||
font-size: var(--text-md);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
margin: 0 0 12px;
|
||||
color: var(--text1);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.paragraph:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.inlineLink {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--accent, #a78bfa);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.inlineLink:hover {
|
||||
color: var(--text0);
|
||||
}
|
||||
|
||||
.nav {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
align-content: start;
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--panel2);
|
||||
}
|
||||
|
||||
.navItem {
|
||||
text-align: left;
|
||||
padding: 9px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.navItem:hover {
|
||||
background: var(--scene-list-hover-bg);
|
||||
color: var(--text0);
|
||||
}
|
||||
|
||||
.navItemActive {
|
||||
background: rgba(167, 139, 250, 0.18);
|
||||
color: var(--text0);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.page {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(0, 1fr) minmax(120px, 32vh);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import { buildHelpLinkCatalog, splitHelpTextWithLinks } from '../editor/help/helpLinkify';
|
||||
import {
|
||||
HELP_SECTION_IDS,
|
||||
helpSectionBodyKey,
|
||||
helpSectionTitleKey,
|
||||
parseHelpSectionId,
|
||||
type HelpSectionId,
|
||||
} from '../editor/help/helpSections';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
|
||||
import styles from './InstructionsApp.module.css';
|
||||
|
||||
function InstructionsPanel({ initialSection }: { initialSection: HelpSectionId }) {
|
||||
const { t } = useEditorI18n();
|
||||
const [activeId, setActiveId] = useState<HelpSectionId>(initialSection);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const navRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveId(initialSection);
|
||||
}, [initialSection]);
|
||||
|
||||
useEffect(() => {
|
||||
contentRef.current?.scrollTo({ top: 0 });
|
||||
const activeNav = navRef.current?.querySelector<HTMLElement>('[aria-current="true"]');
|
||||
activeNav?.scrollIntoView({ block: 'nearest' });
|
||||
}, [activeId]);
|
||||
|
||||
const linkCatalog = useMemo(
|
||||
() => buildHelpLinkCatalog((id) => t(helpSectionTitleKey(id))),
|
||||
[t],
|
||||
);
|
||||
|
||||
const body = t(helpSectionBodyKey(activeId));
|
||||
const paragraphs = body.split('\n\n').filter((p) => p.trim() !== '');
|
||||
|
||||
return (
|
||||
<div className={styles.page} data-testid="instructions-app">
|
||||
<div ref={contentRef} className={styles.content}>
|
||||
<div className={styles.contentTitle}>{t(helpSectionTitleKey(activeId))}</div>
|
||||
{paragraphs.map((p, i) => (
|
||||
<p key={i} className={styles.paragraph}>
|
||||
{splitHelpTextWithLinks(p, linkCatalog).map((part, j) =>
|
||||
part.type === 'text' ? (
|
||||
<React.Fragment key={j}>{part.value}</React.Fragment>
|
||||
) : (
|
||||
<button
|
||||
key={j}
|
||||
type="button"
|
||||
className={styles.inlineLink}
|
||||
onClick={() => setActiveId(part.id)}
|
||||
>
|
||||
{part.value}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<nav ref={navRef} className={styles.nav} aria-label={t('help.navAria')}>
|
||||
{HELP_SECTION_IDS.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={[styles.navItem, id === activeId ? styles.navItemActive : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-current={id === activeId ? 'true' : undefined}
|
||||
onClick={() => setActiveId(id)}
|
||||
>
|
||||
{t(helpSectionTitleKey(id))}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InstructionsApp() {
|
||||
const [section, setSection] = useState<HelpSectionId>('overview');
|
||||
|
||||
useEffect(() => {
|
||||
const api = getDndApi();
|
||||
void api.invoke(ipcChannels.windows.getInstructionsSection, {}).then(({ section: next }) => {
|
||||
setSection(parseHelpSectionId(next));
|
||||
});
|
||||
return api.on(ipcChannels.windows.instructionsSection, ({ section: next }) => {
|
||||
setSection(parseHelpSectionId(next));
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') window.close();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
return <InstructionsPanel initialSection={section} />;
|
||||
}
|
||||
@@ -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 { InstructionsApp } from './InstructionsApp';
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (!rootEl) {
|
||||
throw new Error('Missing #root element');
|
||||
}
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<WindowErrorBoundary title="Instructions">
|
||||
<EditorI18nProvider>
|
||||
<InstructionsApp />
|
||||
</EditorI18nProvider>
|
||||
</WindowErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
Reference in New Issue
Block a user