track user progress
This commit is contained in:
parent
3c92fbddca
commit
b61a669344
3 changed files with 221 additions and 29 deletions
37
src/App.tsx
37
src/App.tsx
|
|
@ -13,18 +13,30 @@ export type WallpaperType = 'xp' | 'win7' | 'win10';
|
||||||
|
|
||||||
type OverlayType = 'case1' | 'case2' | 'case3' | 'case4' | 'case5' | 'deepfake' | 'quiz' | 'wiki' | null;
|
type OverlayType = 'case1' | 'case2' | 'case3' | 'case4' | 'case5' | 'deepfake' | 'quiz' | 'wiki' | null;
|
||||||
|
|
||||||
|
const PROGRESS_KEY = 'cybersec_progress';
|
||||||
|
|
||||||
|
const loadProgress = (): Record<string, boolean> => {
|
||||||
|
try { return JSON.parse(localStorage.getItem(PROGRESS_KEY) || '{}'); }
|
||||||
|
catch { return {}; }
|
||||||
|
};
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const [overlay, setOverlay] = useState<OverlayType>(null);
|
const [overlay, setOverlay] = useState<OverlayType>(null);
|
||||||
|
const [completedItems, setCompletedItems] = useState<Record<string, boolean>>(loadProgress);
|
||||||
|
|
||||||
|
const markComplete = (key: string) => {
|
||||||
|
setCompletedItems(prev => {
|
||||||
|
const next = { ...prev, [key]: true };
|
||||||
|
localStorage.setItem(PROGRESS_KEY, JSON.stringify(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const close = () => setOverlay(null);
|
const close = () => setOverlay(null);
|
||||||
|
|
||||||
const handleSelectLevel = (level: number | string) => {
|
const handleSelectLevel = (level: number | string) => {
|
||||||
const map: Record<string | number, OverlayType> = {
|
const map: Record<string | number, OverlayType> = {
|
||||||
1: 'case1',
|
1: 'case1', 2: 'case2', 3: 'case3', 4: 'case4', 5: 'case5',
|
||||||
2: 'case2',
|
|
||||||
3: 'case3',
|
|
||||||
4: 'case4',
|
|
||||||
5: 'case5',
|
|
||||||
'deepfake': 'deepfake',
|
'deepfake': 'deepfake',
|
||||||
};
|
};
|
||||||
setTimeout(() => setOverlay(map[level] ?? null), 1200);
|
setTimeout(() => setOverlay(map[level] ?? null), 1200);
|
||||||
|
|
@ -34,8 +46,9 @@ const App: React.FC = () => {
|
||||||
<div style={{ width: '100vw', height: '100vh', overflow: 'hidden', position: 'relative' }}>
|
<div style={{ width: '100vw', height: '100vh', overflow: 'hidden', position: 'relative' }}>
|
||||||
<CybersecMenu
|
<CybersecMenu
|
||||||
onSelectLevel={handleSelectLevel}
|
onSelectLevel={handleSelectLevel}
|
||||||
onOpenWiki={() => setTimeout(() => setOverlay('wiki'), 1200)}
|
onOpenWiki={() => { markComplete('wiki'); setTimeout(() => setOverlay('wiki'), 1200); }}
|
||||||
onOpenQuiz={() => setTimeout(() => setOverlay('quiz'), 1200)}
|
onOpenQuiz={() => { markComplete('quiz'); setTimeout(() => setOverlay('quiz'), 1200); }}
|
||||||
|
completedItems={completedItems}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{overlay && (
|
{overlay && (
|
||||||
|
|
@ -54,11 +67,11 @@ const App: React.FC = () => {
|
||||||
}}>✕ ЗАКРЫТЬ</button>
|
}}>✕ ЗАКРЫТЬ</button>
|
||||||
<div style={{ clear: 'both' }} />
|
<div style={{ clear: 'both' }} />
|
||||||
|
|
||||||
{overlay === 'case1' && <Case1Desktop onComplete={close} />}
|
{overlay === 'case1' && <Case1Desktop onComplete={() => { markComplete('case1'); close(); }} />}
|
||||||
{overlay === 'case2' && <Case2Desktop onComplete={close} />}
|
{overlay === 'case2' && <Case2Desktop onComplete={() => { markComplete('case2'); close(); }} />}
|
||||||
{overlay === 'case3' && <Case3Desktop onComplete={close} />}
|
{overlay === 'case3' && <Case3Desktop onComplete={() => { markComplete('case3'); close(); }} />}
|
||||||
{overlay === 'case4' && <Case4Desktop onComplete={close} />}
|
{overlay === 'case4' && <Case4Desktop onComplete={() => { markComplete('case4'); close(); }} />}
|
||||||
{overlay === 'case5' && <Case5Desktop onComplete={close} />}
|
{overlay === 'case5' && <Case5Desktop onComplete={() => { markComplete('case5'); close(); }} />}
|
||||||
{overlay === 'deepfake' && <ChatterboxTTS />}
|
{overlay === 'deepfake' && <ChatterboxTTS />}
|
||||||
{overlay === 'quiz' && <MyQuize />}
|
{overlay === 'quiz' && <MyQuize />}
|
||||||
{overlay === 'wiki' && <CyberSecurityArticle />}
|
{overlay === 'wiki' && <CyberSecurityArticle />}
|
||||||
|
|
|
||||||
|
|
@ -384,6 +384,7 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
position: relative;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
.csm-block-shape::before {
|
.csm-block-shape::before {
|
||||||
|
|
@ -459,6 +460,124 @@
|
||||||
border-top-color: var(--csm-magenta);
|
border-top-color: var(--csm-magenta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Progress bar */
|
||||||
|
.csm-progress-bar {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.csm-progress-label {
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.csm-progress-count {
|
||||||
|
color: var(--csm-green);
|
||||||
|
}
|
||||||
|
.csm-progress-track {
|
||||||
|
width: 220px;
|
||||||
|
height: 3px;
|
||||||
|
background: rgba(0,255,255,0.08);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.csm-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--csm-green), var(--csm-cyan));
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.6s ease;
|
||||||
|
box-shadow: 0 0 8px rgba(0,255,65,0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Done badge on hexagon */
|
||||||
|
.csm-done-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: -4px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--csm-green);
|
||||||
|
color: #000;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 3;
|
||||||
|
box-shadow: 0 0 8px rgba(0,255,65,0.8);
|
||||||
|
font-family: 'Orbitron', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Done state dims the hexagon border to green */
|
||||||
|
.csm-done .csm-hexagon-bg {
|
||||||
|
stroke: var(--csm-green);
|
||||||
|
filter: drop-shadow(0 0 6px rgba(0,255,65,0.4));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Done badge on wiki/quiz blocks */
|
||||||
|
.csm-block-done {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--csm-green);
|
||||||
|
color: #000;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 0 8px rgba(0,255,65,0.8);
|
||||||
|
font-family: 'Orbitron', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ironic ticker */
|
||||||
|
.csm-ticker {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 32px;
|
||||||
|
background: rgba(0,0,0,0.9);
|
||||||
|
border-top: 1px solid rgba(255,0,64,0.3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
.csm-ticker-label {
|
||||||
|
font-family: 'Orbitron', monospace;
|
||||||
|
font-size: 9px;
|
||||||
|
color: #FF0040;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
padding: 0 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-right: 1px solid rgba(255,0,64,0.3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.csm-ticker-track {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.csm-ticker-msg {
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #aaa;
|
||||||
|
white-space: nowrap;
|
||||||
|
animation: csm-ticker-fade 0.4s ease;
|
||||||
|
}
|
||||||
|
@keyframes csm-ticker-fade {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
/* Notification */
|
/* Notification */
|
||||||
.csm-notification {
|
.csm-notification {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,25 @@ interface CybersecMenuProps {
|
||||||
onSelectLevel?: (level: number | string) => void;
|
onSelectLevel?: (level: number | string) => void;
|
||||||
onOpenWiki?: () => void;
|
onOpenWiki?: () => void;
|
||||||
onOpenQuiz?: () => void;
|
onOpenQuiz?: () => void;
|
||||||
|
completedItems?: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CybersecMenu: React.FC<CybersecMenuProps> = ({ onSelectLevel, onOpenWiki, onOpenQuiz }) => {
|
const TICKER_MESSAGES = [
|
||||||
|
'👤 Кто-то только что открыл вложение из письма «Вы выиграли iPhone»...',
|
||||||
|
'🔴 Иван Иваныч ввёл пароль на фишинговом сайте. Снова.',
|
||||||
|
'⚠️ Целых 5 минут никто не лажал! Ам нет, поспешил..',
|
||||||
|
'💾 Кто-то скачал ТелеЛитр с сайта telelitr-skachat.ru',
|
||||||
|
'📶 Неизвестный подключился к открытому WiFi «FREE_COFFEE_HACKER»',
|
||||||
|
'🛒 Пользователь ввёл данные карты на сайте yandex-market-shop.ru.biz',
|
||||||
|
'🔑 Сисадмин сменил пароль с «123456» на «1234567». Надёжно!',
|
||||||
|
'🎭 Голосовой дипфейк гендиректора попросил перевести 3 млн. Перевели.',
|
||||||
|
'💻 Бухгалтерия обновила Windows XP... до Windows XP SP3. Прогресс!',
|
||||||
|
'📧 Оп, Иван Иваныч слил данные — а сольёшь ли их ты?',
|
||||||
|
'🚨 Кто-то скомпрометировал все рабочие данные. Понедельник удался.',
|
||||||
|
'🔓 Пароль «qwerty123» снова признан самым популярным в офисе.',
|
||||||
|
];
|
||||||
|
|
||||||
|
const CybersecMenu: React.FC<CybersecMenuProps> = ({ onSelectLevel, onOpenWiki, onOpenQuiz, completedItems = {} }) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const animFrameRef = useRef<number>(0);
|
const animFrameRef = useRef<number>(0);
|
||||||
const smokeOffsetRef = useRef(0);
|
const smokeOffsetRef = useRef(0);
|
||||||
|
|
@ -22,6 +38,7 @@ const CybersecMenu: React.FC<CybersecMenuProps> = ({ onSelectLevel, onOpenWiki,
|
||||||
const [notification, setNotification] = useState<Notification | null>(null);
|
const [notification, setNotification] = useState<Notification | null>(null);
|
||||||
const notifIdRef = useRef(0);
|
const notifIdRef = useRef(0);
|
||||||
const notifTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const notifTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const [tickerIdx, setTickerIdx] = useState(0);
|
||||||
|
|
||||||
const showNotification = useCallback((title: string, message: string) => {
|
const showNotification = useCallback((title: string, message: string) => {
|
||||||
if (notifTimerRef.current) clearTimeout(notifTimerRef.current);
|
if (notifTimerRef.current) clearTimeout(notifTimerRef.current);
|
||||||
|
|
@ -57,6 +74,14 @@ const CybersecMenu: React.FC<CybersecMenuProps> = ({ onSelectLevel, onOpenWiki,
|
||||||
onOpenQuiz?.();
|
onOpenQuiz?.();
|
||||||
}, [showNotification, onOpenQuiz]);
|
}, [showNotification, onOpenQuiz]);
|
||||||
|
|
||||||
|
// Ticker cycling
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => {
|
||||||
|
setTickerIdx(i => (i + 1) % TICKER_MESSAGES.length);
|
||||||
|
}, 8000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Keyboard navigation
|
// Keyboard navigation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
|
@ -236,6 +261,25 @@ const CybersecMenu: React.FC<CybersecMenuProps> = ({ onSelectLevel, onOpenWiki,
|
||||||
<div className="csm-header">
|
<div className="csm-header">
|
||||||
<h1 ref={titleRef}>CYBERSEC TRAINING</h1>
|
<h1 ref={titleRef}>CYBERSEC TRAINING</h1>
|
||||||
<div className="csm-subtitle">[ TACTICAL SECURITY SIMULATION ]</div>
|
<div className="csm-subtitle">[ TACTICAL SECURITY SIMULATION ]</div>
|
||||||
|
<div className="csm-progress-bar">
|
||||||
|
{(() => {
|
||||||
|
const total = menuItems.length + 2; // cases + deepfake + wiki + quiz
|
||||||
|
const done = [
|
||||||
|
...menuItems.map(m => String(m.level)),
|
||||||
|
'wiki', 'quiz',
|
||||||
|
].filter(k => completedItems[k]).length;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="csm-progress-label">
|
||||||
|
ПРОГРЕСС: <span className="csm-progress-count">{done}/{total}</span>
|
||||||
|
</div>
|
||||||
|
<div className="csm-progress-track">
|
||||||
|
<div className="csm-progress-fill" style={{ width: `${(done / total) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Character */}
|
{/* Character */}
|
||||||
|
|
@ -325,39 +369,55 @@ const CybersecMenu: React.FC<CybersecMenuProps> = ({ onSelectLevel, onOpenWiki,
|
||||||
|
|
||||||
{/* Right Side Menu */}
|
{/* Right Side Menu */}
|
||||||
<div className="csm-right-menu">
|
<div className="csm-right-menu">
|
||||||
{menuItems.map(item => (
|
{menuItems.map(item => {
|
||||||
<div key={String(item.level)} className={`csm-menu-item ${item.cls}`} onClick={() => selectLevel(item.level)}>
|
const isDone = completedItems[String(item.level)];
|
||||||
<div className="csm-hexagon">
|
return (
|
||||||
<svg className="csm-hexagon-bg" viewBox="0 0 70 80">
|
<div key={String(item.level)} className={`csm-menu-item ${item.cls}${isDone ? ' csm-done' : ''}`} onClick={() => selectLevel(item.level)}>
|
||||||
<polygon points="35,2 66,20 66,58 35,76 4,58 4,20"/>
|
<div className="csm-hexagon">
|
||||||
</svg>
|
<svg className="csm-hexagon-bg" viewBox="0 0 70 80">
|
||||||
<span className="csm-hexagon-icon">{item.icon}</span>
|
<polygon points="35,2 66,20 66,58 35,76 4,58 4,20"/>
|
||||||
|
</svg>
|
||||||
|
<span className="csm-hexagon-icon">{item.icon}</span>
|
||||||
|
{isDone && <span className="csm-done-badge">✓</span>}
|
||||||
|
</div>
|
||||||
|
<div className="csm-tooltip">
|
||||||
|
<div className="csm-tooltip-title">{item.title}</div>
|
||||||
|
<div className="csm-tooltip-desc">{item.desc}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="csm-tooltip">
|
);
|
||||||
<div className="csm-tooltip-title">{item.title}</div>
|
})}
|
||||||
<div className="csm-tooltip-desc">{item.desc}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Container */}
|
{/* Bottom Container */}
|
||||||
<div className="csm-bottom-container">
|
<div className="csm-bottom-container">
|
||||||
<div className="csm-bottom-block" onClick={openWiki}>
|
<div className={`csm-bottom-block${completedItems['wiki'] ? ' csm-done' : ''}`} onClick={openWiki}>
|
||||||
<div className="csm-block-shape">
|
<div className="csm-block-shape">
|
||||||
<span className="csm-block-icon">📖</span>
|
<span className="csm-block-icon">📖</span>
|
||||||
<span className="csm-block-text">WIKI</span>
|
<span className="csm-block-text">WIKI</span>
|
||||||
|
{completedItems['wiki'] && <span className="csm-block-done">✓</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="csm-block-tooltip">База знаний по безопастности</div>
|
<div className="csm-block-tooltip">База знаний по безопасности</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="csm-bottom-block" onClick={openQuiz}>
|
<div className={`csm-bottom-block${completedItems['quiz'] ? ' csm-done' : ''}`} onClick={openQuiz}>
|
||||||
<div className="csm-block-shape">
|
<div className="csm-block-shape">
|
||||||
<span className="csm-block-icon">❓</span>
|
<span className="csm-block-icon">❓</span>
|
||||||
<span className="csm-block-text">QUIZ</span>
|
<span className="csm-block-text">QUIZ</span>
|
||||||
|
{completedItems['quiz'] && <span className="csm-block-done">✓</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="csm-block-tooltip">Проверь свои знания!</div>
|
<div className="csm-block-tooltip">Проверь свои знания!</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Ironic ticker */}
|
||||||
|
<div className="csm-ticker">
|
||||||
|
<span className="csm-ticker-label">// LIVE FEED</span>
|
||||||
|
<div className="csm-ticker-track">
|
||||||
|
<span key={tickerIdx} className="csm-ticker-msg">
|
||||||
|
{TICKER_MESSAGES[tickerIdx]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notification */}
|
{/* Notification */}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue