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;
|
||||
|
||||
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 [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 handleSelectLevel = (level: number | string) => {
|
||||
const map: Record<string | number, OverlayType> = {
|
||||
1: 'case1',
|
||||
2: 'case2',
|
||||
3: 'case3',
|
||||
4: 'case4',
|
||||
5: 'case5',
|
||||
1: 'case1', 2: 'case2', 3: 'case3', 4: 'case4', 5: 'case5',
|
||||
'deepfake': 'deepfake',
|
||||
};
|
||||
setTimeout(() => setOverlay(map[level] ?? null), 1200);
|
||||
|
|
@ -34,8 +46,9 @@ const App: React.FC = () => {
|
|||
<div style={{ width: '100vw', height: '100vh', overflow: 'hidden', position: 'relative' }}>
|
||||
<CybersecMenu
|
||||
onSelectLevel={handleSelectLevel}
|
||||
onOpenWiki={() => setTimeout(() => setOverlay('wiki'), 1200)}
|
||||
onOpenQuiz={() => setTimeout(() => setOverlay('quiz'), 1200)}
|
||||
onOpenWiki={() => { markComplete('wiki'); setTimeout(() => setOverlay('wiki'), 1200); }}
|
||||
onOpenQuiz={() => { markComplete('quiz'); setTimeout(() => setOverlay('quiz'), 1200); }}
|
||||
completedItems={completedItems}
|
||||
/>
|
||||
|
||||
{overlay && (
|
||||
|
|
@ -54,11 +67,11 @@ const App: React.FC = () => {
|
|||
}}>✕ ЗАКРЫТЬ</button>
|
||||
<div style={{ clear: 'both' }} />
|
||||
|
||||
{overlay === 'case1' && <Case1Desktop onComplete={close} />}
|
||||
{overlay === 'case2' && <Case2Desktop onComplete={close} />}
|
||||
{overlay === 'case3' && <Case3Desktop onComplete={close} />}
|
||||
{overlay === 'case4' && <Case4Desktop onComplete={close} />}
|
||||
{overlay === 'case5' && <Case5Desktop onComplete={close} />}
|
||||
{overlay === 'case1' && <Case1Desktop onComplete={() => { markComplete('case1'); close(); }} />}
|
||||
{overlay === 'case2' && <Case2Desktop onComplete={() => { markComplete('case2'); close(); }} />}
|
||||
{overlay === 'case3' && <Case3Desktop onComplete={() => { markComplete('case3'); close(); }} />}
|
||||
{overlay === 'case4' && <Case4Desktop onComplete={() => { markComplete('case4'); close(); }} />}
|
||||
{overlay === 'case5' && <Case5Desktop onComplete={() => { markComplete('case5'); close(); }} />}
|
||||
{overlay === 'deepfake' && <ChatterboxTTS />}
|
||||
{overlay === 'quiz' && <MyQuize />}
|
||||
{overlay === 'wiki' && <CyberSecurityArticle />}
|
||||
|
|
|
|||
|
|
@ -384,6 +384,7 @@
|
|||
justify-content: center;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.csm-block-shape::before {
|
||||
|
|
@ -459,6 +460,124 @@
|
|||
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 */
|
||||
.csm-notification {
|
||||
position: fixed;
|
||||
|
|
|
|||
|
|
@ -12,9 +12,25 @@ interface CybersecMenuProps {
|
|||
onSelectLevel?: (level: number | string) => void;
|
||||
onOpenWiki?: () => 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 animFrameRef = useRef<number>(0);
|
||||
const smokeOffsetRef = useRef(0);
|
||||
|
|
@ -22,6 +38,7 @@ const CybersecMenu: React.FC<CybersecMenuProps> = ({ onSelectLevel, onOpenWiki,
|
|||
const [notification, setNotification] = useState<Notification | null>(null);
|
||||
const notifIdRef = useRef(0);
|
||||
const notifTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [tickerIdx, setTickerIdx] = useState(0);
|
||||
|
||||
const showNotification = useCallback((title: string, message: string) => {
|
||||
if (notifTimerRef.current) clearTimeout(notifTimerRef.current);
|
||||
|
|
@ -57,6 +74,14 @@ const CybersecMenu: React.FC<CybersecMenuProps> = ({ onSelectLevel, onOpenWiki,
|
|||
onOpenQuiz?.();
|
||||
}, [showNotification, onOpenQuiz]);
|
||||
|
||||
// Ticker cycling
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setTickerIdx(i => (i + 1) % TICKER_MESSAGES.length);
|
||||
}, 8000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
|
|
@ -236,6 +261,25 @@ const CybersecMenu: React.FC<CybersecMenuProps> = ({ onSelectLevel, onOpenWiki,
|
|||
<div className="csm-header">
|
||||
<h1 ref={titleRef}>CYBERSEC TRAINING</h1>
|
||||
<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>
|
||||
|
||||
{/* Character */}
|
||||
|
|
@ -325,39 +369,55 @@ const CybersecMenu: React.FC<CybersecMenuProps> = ({ onSelectLevel, onOpenWiki,
|
|||
|
||||
{/* Right Side Menu */}
|
||||
<div className="csm-right-menu">
|
||||
{menuItems.map(item => (
|
||||
<div key={String(item.level)} className={`csm-menu-item ${item.cls}`} onClick={() => selectLevel(item.level)}>
|
||||
<div className="csm-hexagon">
|
||||
<svg className="csm-hexagon-bg" viewBox="0 0 70 80">
|
||||
<polygon points="35,2 66,20 66,58 35,76 4,58 4,20"/>
|
||||
</svg>
|
||||
<span className="csm-hexagon-icon">{item.icon}</span>
|
||||
{menuItems.map(item => {
|
||||
const isDone = completedItems[String(item.level)];
|
||||
return (
|
||||
<div key={String(item.level)} className={`csm-menu-item ${item.cls}${isDone ? ' csm-done' : ''}`} onClick={() => selectLevel(item.level)}>
|
||||
<div className="csm-hexagon">
|
||||
<svg className="csm-hexagon-bg" viewBox="0 0 70 80">
|
||||
<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 className="csm-tooltip">
|
||||
<div className="csm-tooltip-title">{item.title}</div>
|
||||
<div className="csm-tooltip-desc">{item.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<span className="csm-block-icon">📖</span>
|
||||
<span className="csm-block-text">WIKI</span>
|
||||
{completedItems['wiki'] && <span className="csm-block-done">✓</span>}
|
||||
</div>
|
||||
<div className="csm-block-tooltip">База знаний по безопастности</div>
|
||||
<div className="csm-block-tooltip">База знаний по безопасности</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">
|
||||
<span className="csm-block-icon">❓</span>
|
||||
<span className="csm-block-text">QUIZ</span>
|
||||
{completedItems['quiz'] && <span className="csm-block-done">✓</span>}
|
||||
</div>
|
||||
<div className="csm-block-tooltip">Проверь свои знания!</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>
|
||||
|
||||
{/* Notification */}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue