Files
DndGamePlayer/app/renderer/editor/about/EditorAboutModals.tsx
T
Ivan Fontosh 712086ddca feat: кубики/таймер/кисть/спонсоры и задел игровых систем
Показ анимации кубиков игрокам, закрытие оверлеев с окнами, ручной ввод количества, меньший радиус кисти, блок спонсоров в «О программе»; плюс черновик game systems / character sheet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 15:37:44 +08:00

244 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { APP_DISPLAY_NAME_EN, APP_DISPLAY_NAME_RU } from '../../../shared/appBranding';
import { Button } from '../../shared/ui/controls';
import styles from '../EditorApp.module.css';
import { buildHelpLinkCatalog, splitHelpTextWithLinks } from '../help/helpLinkify';
import {
HELP_SECTION_IDS,
helpSectionBodyKey,
helpSectionTitleKey,
type HelpSectionId,
} from '../help/helpSections';
import { useEditorI18n } from '../i18n/EditorI18nContext';
const ABOUT_SPONSORS = [
{ kind: 'text', name: 'Исаков Артур' },
{ kind: 'text', name: 'Подчуфарова Евгения' },
{ kind: 'text', name: 'Никитин Виктор' },
{ kind: 'text', name: 'Майер С.В. (Клуб «Серый Май»)' },
{ kind: 'link', name: 'true_rus(КАВ)', url: 'https://truecorp.su/' },
{ kind: 'text', name: 'Макс LonelyFlame Чурсин' },
] as const;
type AppAboutModalProps = {
open: boolean;
onClose: () => void;
appVersion: string | null;
};
export function AppAboutModal({ open, onClose, appVersion }: AppAboutModalProps) {
const { t, locale } = useEditorI18n();
const appName = locale === 'ru' ? APP_DISPLAY_NAME_RU : APP_DISPLAY_NAME_EN;
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open]);
if (!open) return null;
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('app.about.title')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div className={styles.aboutBody}>
<div className={styles.aboutAppName}>{appName}</div>
<div className={styles.aboutTagline}>{t('app.about.tagline')}</div>
<p className={styles.aboutParagraph}>{t('app.about.description')}</p>
<div className={styles.aboutMetaGrid}>
<div className={styles.fieldLabel}>{t('app.about.versionLabel')}</div>
<div>{appVersion ?? '—'}</div>
<div className={styles.fieldLabel}>{t('app.about.developerLabel')}</div>
<div>{t('app.about.developer')}</div>
<div className={styles.fieldLabel}>{t('app.about.supportLabel')}</div>
<div>
<a className={styles.aboutLink} href={`mailto:${t('app.about.supportEmail')}`}>
{t('app.about.supportEmail')}
</a>
</div>
<div className={styles.fieldLabel}>{t('app.about.websiteLabel')}</div>
<div>
<a
className={styles.aboutLink}
href={t('app.about.websiteUrl')}
target="_blank"
rel="noreferrer"
>
{t('app.about.websiteUrl')}
</a>
</div>
<div className={styles.fieldLabel}>{t('app.about.sponsorsLabel')}</div>
<div className={styles.aboutSponsorsList}>
{ABOUT_SPONSORS.map((sponsor) =>
sponsor.kind === 'link' ? (
<a
key={sponsor.name}
className={styles.aboutLink}
href={sponsor.url}
target="_blank"
rel="noreferrer"
>
{sponsor.name}
</a>
) : (
<div key={sponsor.name}>{sponsor.name}</div>
),
)}
</div>
</div>
</div>
<div className={styles.modalFooter}>
<Button variant="primary" onClick={onClose}>
{t('common.close')}
</Button>
</div>
</div>
</>,
document.body,
);
}
type InstructionsModalProps = {
open: boolean;
onClose: () => void;
initialSection?: HelpSectionId;
};
function InstructionsModalBody({
initialSection,
onClose,
}: {
initialSection: HelpSectionId;
onClose: () => void;
}) {
const { t } = useEditorI18n();
const [activeId, setActiveId] = useState<HelpSectionId>(initialSection);
const contentRef = useRef<HTMLDivElement | null>(null);
const navRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
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() !== '');
const goToSection = (id: HelpSectionId) => {
setActiveId(id);
};
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.instructionsDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('help.title')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div className={styles.instructionsLayout}>
<div ref={contentRef} className={styles.instructionsContent}>
<div className={styles.instructionsContentTitle}>{t(helpSectionTitleKey(activeId))}</div>
{paragraphs.map((p, i) => (
<p key={i} className={styles.instructionsParagraph}>
{splitHelpTextWithLinks(p, linkCatalog).map((part, j) =>
part.type === 'text' ? (
<React.Fragment key={j}>{part.value}</React.Fragment>
) : (
<button
key={j}
type="button"
className={styles.instructionsInlineLink}
onClick={() => goToSection(part.id)}
>
{part.value}
</button>
),
)}
</p>
))}
</div>
<nav ref={navRef} className={styles.instructionsNav} aria-label={t('help.navAria')}>
{HELP_SECTION_IDS.map((id) => (
<button
key={id}
type="button"
className={[
styles.instructionsNavItem,
id === activeId ? styles.instructionsNavItemActive : '',
]
.filter(Boolean)
.join(' ')}
aria-current={id === activeId ? 'true' : undefined}
onClick={() => goToSection(id)}
>
{t(helpSectionTitleKey(id))}
</button>
))}
</nav>
</div>
<div className={styles.modalFooter}>
<Button variant="primary" onClick={onClose}>
{t('common.close')}
</Button>
</div>
</div>
</>,
document.body,
);
}
export function InstructionsModal({ open, onClose, initialSection }: InstructionsModalProps) {
if (!open) return null;
const section = initialSection ?? 'overview';
return <InstructionsModalBody key={section} initialSection={section} onClose={onClose} />;
}