// ===================================================== // SentinelAI Command Center - Main Controller // ===================================================== // ---- State ---- const state = { startTime: Date.now(), scanQueue: 0, threatCount: 247, scanCount: 1832, vulnCount: 54, assetCount: 128, alerts: [], logs: [], discoveries: [], targets: [], sparklineData: [], scanning: false }; // ---- Threat Map Data (simulated world coordinates) ---- const threatZones = [ { name: 'North America', lat: 39.8, lng: -98.6, threats: 45, level: 'high' }, { name: 'Europe', lat: 50.1, lng: 9.8, threats: 38, level: 'medium' }, { name: 'East Asia', lat: 35.9, lng: 104.2, threats: 52, level: 'critical' }, { name: 'South Asia', lat: 20.6, lng: 78.9, threats: 21, level: 'medium' }, { name: 'Middle East', lat: 29.5, lng: 45.5, threats: 15, level: 'high' }, { name: 'South America', lat: -14.2, lng: -51.9, threats: 12, level: 'low' }, { name: 'Africa', lat: 6.6, lng: 20.9, threats: 8, level: 'low' }, { name: 'Oceania', lat: -25.3, lng: 133.8, threats: 6, level: 'low' }, { name: 'Russia', lat: 61.5, lng: 105.3, threats: 33, level: 'high' }, { name: 'SE Asia', lat: 1.4, lng: 103.8, threats: 18, level: 'medium' }, ]; // ---- Alert Templates ---- const alertTemplates = [ { severity: 'critical', icon: 'shield-alert', messages: [ 'SQL Injection attempt detected on /api/v2/users', 'Brute force attack on SSH port 22 from 185.x.x.x', 'Data exfiltration anomaly detected - 2.3GB outbound', 'Zero-day exploit CVE-2024-XXXX detected in wild', ]}, { severity: 'high', icon: 'alert-triangle', messages: [ 'Unusual login pattern from geographic outlier', 'DDoS attack mitigation active - 15Gbps filtered', 'Exposed .git repository found on subdomain', 'Credential stuffing attack on login endpoint', ]}, { severity: 'medium', icon: 'info', messages: [ 'New subdomain discovered: dev.internal.target.com', 'SSL certificate expiring in 14 days', 'Port 8080 newly opened on monitored asset', 'API rate limit threshold reached for key X', ]}, { severity: 'low', icon: 'bell', messages: [ 'Scheduled scan completed for sector A', 'DNS record change detected', 'New HTTP header observed on endpoint', 'Cookie security flag missing on staging', ]} ]; // ---- Discovery Templates ---- const discoveryTemplates = [ { type: 'subdomain', icon: 'globe', color: 'cyan', msg: 'Subdomain found: {sub}.target.com' }, { type: 'port', icon: 'plug', color: 'amber', msg: 'Open port {port} detected on {ip}' }, { type: 'vuln', icon: 'bug', color: 'red', msg: 'XSS vulnerability in /search?q=' }, { type: 'dork', icon: 'search', color: 'purple', msg: 'Google Dork match: ext:sql site:target.com' }, { type: 'config', icon: 'file-warning', color: 'orange', msg: 'Exposed config file: /.env' }, { type: 'api', icon: 'code', color: 'blue', msg: 'Unauthenticated API endpoint: /api/v1/admin' }, ]; // ---- Target Data ---- const targetData = [ { name: 'prod-server-01', ip: '10.0.1.15', hits: 847, severity: 'critical' }, { name: 'web-gateway', ip: '10.0.1.1', hits: 623, severity: 'high' }, { name: 'db-cluster-master', ip: '10.0.2.10', hits: 412, severity: 'high' }, { name: 'api-load-balancer', ip: '10.0.1.50', hits: 289, severity: 'medium' }, { name: 'cdn-edge-node-03', ip: '10.0.3.22', hits: 156, severity: 'medium' }, { name: 'staging-env', ip: '10.0.4.5', hits: 98, severity: 'low' }, { name: 'dev-sandbox', ip: '10.0.5.12', hits: 34, severity: 'low' }, ]; // ---- Initialize ---- document.addEventListener('DOMContentLoaded', () => { lucide.createIcons(); initCounters(); initAlerts(); initDiscoveries(); initTargets(); initSparkline(); initThreatMap(); startClock(); startOperationalMode(); }); // ---- Clock & Uptime ---- function startClock() { function update() { const now = new Date(); const timeEl = document.getElementById('systemTime'); if (timeEl) timeEl.textContent = now.toUTCString().split(' ')[4] + ' UTC'; const uptimeEl = document.getElementById('uptimeCounter'); if (uptimeEl) { const diff = Date.now() - state.startTime; const d = Math.floor(diff / 86400000); const h = Math.floor((diff % 86400000) / 3600000); const m = Math.floor((diff % 3600000) / 60000); uptimeEl.textContent = `${d}d ${h}h ${m}m`; } } update(); setInterval(update, 1000); } // ---- Counter Animation ---- function animateCounter(el, target, duration = 1500) { if (!el) return; const start = parseInt(el.textContent) || 0; const startTime = performance.now(); function step(currentTime) { const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); const eased = 1 - Math.pow(1 - progress, 3); el.textContent = Math.floor(start + (target - start) * eased).toLocaleString(); if (progress < 1) requestAnimationFrame(step); } requestAnimationFrame(step); } function initCounters() { animateCounter(document.getElementById('threatCount'), state.threatCount); animateCounter(document.getElementById('scanCount'), state.scanCount); animateCounter(document.getElementById('vulnCount'), state.vulnCount); animateCounter(document.getElementById('assetCount'), state.assetCount); } // ---- Alerts ---- async function initAlerts() { await fetchAndAddAlerts(); setInterval(fetchAndAddAlerts, 8000); } function addAlert() { const template = alertTemplates[Math.floor(Math.random() * alertTemplates.length)]; const message = template.messages[Math.floor(Math.random() * template.messages.length)]; const severity = template.severity; const icon = template.icon; const now = new Date(); const timeStr = now.toTimeString().split(' ')[0]; const colorMap = { critical: { bg: 'bg-red-500/10', border: 'border-red-500/20', text: 'text-red-400', badge: 'bg-red-500/20 text-red-400' }, high: { bg: 'bg-amber-500/10', border: 'border-amber-500/20', text: 'text-amber-400', badge: 'bg-amber-500/20 text-amber-400' }, medium: { bg: 'bg-cyan-500/10', border: 'border-cyan-500/20', text: 'text-cyan-400', badge: 'bg-cyan-500/20 text-cyan-400' }, low: { bg: 'bg-slate-500/10', border: 'border-slate-700/30', text: 'text-slate-400', badge: 'bg-slate-500/20 text-slate-400' }, }; const c = colorMap[severity] || colorMap.low; const alertObj = { severity, message, time: timeStr, icon, colors: c }; state.alerts.unshift(alertObj); if (state.alerts.length > 20) state.alerts.pop(); renderAlerts(); updateAlertCount(); } function renderAlerts() { const list = document.getElementById('alertsList'); if (!list) return; list.innerHTML = state.alerts.map((a, i) => `

${a.message}

${a.severity} ${a.time}
`).join(''); lucide.createIcons(); } function updateAlertCount() { const el = document.getElementById('alertCount'); if (el) { const critCount = state.alerts.filter(a => a.severity === 'critical' || a.severity === 'high').length; el.textContent = critCount; } } // ---- Activity Log ---- function addLog(message, type = 'info') { const now = new Date(); const timeStr = now.toTimeString().split(' ')[0]; const colorMap = { info: 'text-slate-400', success: 'text-green-400', warning: 'text-amber-400', error: 'text-red-400', system: 'text-cyan-400', }; const prefixMap = { info: 'INF', success: 'OK ', warning: 'WRN', error: 'ERR', system: 'SYS', }; state.logs.unshift({ message, type, time: timeStr }); if (state.logs.length > 50) state.logs.pop(); renderLogs(); } function renderLogs() { const el = document.getElementById('activityLog'); if (!el) return; const colorMap = { info: 'text-slate-400', success: 'text-green-400', warning: 'text-amber-400', error: 'text-red-400', system: 'text-cyan-400', }; const prefixMap = { info: 'INF', success: 'OK ', warning: 'WRN', error: 'ERR', system: 'SYS', }; el.innerHTML = state.logs.map(l => `
${l.time} [${prefixMap[l.type] || 'INF'}] ${l.message}
`).join(''); } function clearLogs() { state.logs = []; renderLogs(); showToast('Logs cleared'); } // ---- Discoveries ---- function initDiscoveries() { // Discoveries are already populated by fetchAndAddAlerts, no need for simulated ones. setInterval(fetchAndAddAlerts, 12000); } function addDiscovery() { const template = discoveryTemplates[Math.floor(Math.random() * discoveryTemplates.length)]; const subs = ['dev', 'staging', 'api', 'admin', 'cdn', 'mail', 'vpn', 'git', 'app', 'test']; const ports = [22, 80, 443, 3306, 5432, 8080, 8443, 27017, 6379, 9200]; const ips = ['10.0.1.15', '10.0.2.10', '192.168.1.1', '172.16.0.5']; let msg = template.msg .replace('{sub}', subs[Math.floor(Math.random() * subs.length)]) .replace('{port}', ports[Math.floor(Math.random() * ports.length)]) .replace('{ip}', ips[Math.floor(Math.random() * ips.length)]); const now = new Date(); const timeStr = now.toTimeString().split(' ')[0].substring(0, 5); state.discoveries.unshift({ type: template.type, icon: template.icon, color: template.color, msg, time: timeStr }); if (state.discoveries.length > 15) state.discoveries.pop(); renderDiscoveries(); } function renderDiscoveries() { const el = document.getElementById('discoveriesList'); if (!el) return; const colorClasses = { cyan: 'text-cyan-400 bg-cyan-500/10', amber: 'text-amber-400 bg-amber-500/10', red: 'text-red-400 bg-red-500/10', purple: 'text-purple-400 bg-purple-500/10', orange: 'text-orange-400 bg-orange-500/10', blue: 'text-blue-400 bg-blue-500/10', }; el.innerHTML = state.discoveries.map(d => { const cc = colorClasses[d.color] || colorClasses.cyan; const [textC, bgC] = cc.split(' '); return `

${d.msg}

${d.time}
`; }).join(''); lucide.createIcons(); } // ---- Top Targets ---- async function initTargets() { // Utiliser des IPs réelles const ips = ['8.8.8.8', '1.1.1.1', '208.67.222.222', '9.9.9.9', '8.8.4.4', '1.0.0.1', '208.67.220.220']; const results = await SentinelAPI.batchLookup(ips); state.targets = results.map((r, i) => ({ name: r.org ? r.org.split(' ')[0] : `Target-${i+1}`, ip: r.ip || ips[i], hits: Math.floor(Math.random() * 800 + 100), severity: r.country ? 'medium' : 'low', })); renderTargets(); } function renderTargets() { const el = document.getElementById('topTargetsList'); if (!el) return; const sevColors = { critical: 'text-red-400 bg-red-500/10 border-red-500/20', high: 'text-amber-400 bg-amber-500/10 border-amber-500/20', medium: 'text-cyan-400 bg-cyan-500/10 border-cyan-500/20', low: 'text-slate-400 bg-slate-500/10 border-slate-700/30', }; const maxHits = Math.max(...state.targets.map(t => t.hits)); el.innerHTML = state.targets.map(t => { const sc = sevColors[t.severity] || sevColors.low; const [textC, bgC, borderC] = sc.split(' '); const barWidth = (t.hits / maxHits * 100); const barColor = t.severity === 'critical' ? 'bg-red-500' : t.severity === 'high' ? 'bg-amber-500' : t.severity === 'medium' ? 'bg-cyan-500' : 'bg-slate-500'; return `
${t.name}
${t.severity}
${t.ip}
${t.hits}
`; }).join(''); lucide.createIcons(); } // ---- Threat Map (Leaflet) ---- let threatMap = null; let pulseRings = []; function initThreatMap() { const mapEl = document.getElementById('threatMap'); if (!mapEl) return; // Initialize Leaflet map with dark theme threatMap = L.map('threatMap', { center: [20, 0], zoom: 2, minZoom: 2, maxZoom: 8, zoomControl: false, attributionControl: false, worldCopyJump: true, scrollWheelZoom: true, }); // Dark theme tiles (CartoDB Dark Matter) L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { subdomains: 'abcd', maxZoom: 19, }).addTo(threatMap); // Zoom control bottom-right L.control.zoom({ position: 'bottomright' }).addTo(threatMap); // Color map for threat levels const colorMap = { critical: '#dc2626', high: '#f59e0b', medium: '#06b6d4', low: '#64748b', }; // Add threat markers threatZones.forEach(zone => { const color = colorMap[zone.level] || colorMap.low; const intensity = Math.min(zone.threats / 60, 1); const markerRadius = 6 + intensity * 6; // Glow circle (semi-transparent fill) L.circle([zone.lat, zone.lng], { radius: 400000 + intensity * 600000, fillColor: color, color: color, weight: 0, opacity: 0, fillOpacity: 0.08, }).addTo(threatMap); // Main marker const marker = L.circleMarker([zone.lat, zone.lng], { radius: markerRadius, fillColor: color, color: '#0c1222', weight: 2, opacity: 1, fillOpacity: 0.9, }).addTo(threatMap); // Popup marker.bindPopup(`
${zone.name}
${zone.level} · ${zone.threats} threats
`); // Tooltip on hover marker.bindTooltip(`${zone.name} — ${zone.threats} threats`, { direction: 'top', offset: [0, -markerRadius - 4], className: 'threat-tooltip', }); // Pulse ring (animated) const pulseRing = L.circle([zone.lat, zone.lng], { radius: 100000, fillColor: color, color: color, weight: 1.5, opacity: 0.5, fillOpacity: 0, }).addTo(threatMap); pulseRings.push({ ring: pulseRing, color, phase: Math.random() }); }); // Draw connection lines between zones const connections = [[0, 2], [1, 8], [2, 9], [3, 4], [0, 1]]; connections.forEach(([a, b]) => { const za = threatZones[a]; const zb = threatZones[b]; L.polyline([[za.lat, za.lng], [zb.lat, zb.lng]], { color: '#06b6d4', weight: 1, opacity: 0.12, dashArray: '4, 6', }).addTo(threatMap); }); // Fix map size after layout settles setTimeout(() => { if (threatMap) threatMap.invalidateSize(); }, 100); // Start pulse animation animatePulseRings(); } function animatePulseRings() { if (!threatMap) return; pulseRings.forEach(p => { p.phase = (p.phase + 0.008) % 1; const radius = 800000 * p.phase + 80000; const opacity = 0.5 * (1 - p.phase); p.ring.setRadius(radius); p.ring.setStyle({ opacity: opacity, fillOpacity: 0 }); }); requestAnimationFrame(animatePulseRings); } // ---- Sparkline Chart ---- function initSparkline() { for (let i = 0; i < 60; i++) { state.sparklineData.push(Math.random() * 40 + 30); } drawSparkline(); setInterval(() => { state.sparklineData.shift(); state.sparklineData.push(Math.random() * 40 + 30); drawSparkline(); }, 1000); } function drawSparkline() { const canvas = document.getElementById('sparkline'); if (!canvas) return; const rect = canvas.parentElement.getBoundingClientRect(); canvas.width = rect.width; canvas.height = 50; const ctx = canvas.getContext('2d'); const w = canvas.width; const h = canvas.height; const data = state.sparklineData; const max = Math.max(...data); const min = Math.min(...data); const range = max - min || 1; ctx.clearRect(0, 0, w, h); // Fill gradient const gradient = ctx.createLinearGradient(0, 0, 0, h); gradient.addColorStop(0, 'rgba(6, 182, 212, 0.2)'); gradient.addColorStop(1, 'rgba(6, 182, 212, 0)'); ctx.beginPath(); ctx.moveTo(0, h); data.forEach((val, i) => { const x = (i / (data.length - 1)) * w; const y = h - ((val - min) / range) * (h - 4) - 2; if (i === 0) ctx.lineTo(x, y); else ctx.lineTo(x, y); }); ctx.lineTo(w, h); ctx.closePath(); ctx.fillStyle = gradient; ctx.fill(); // Line ctx.beginPath(); data.forEach((val, i) => { const x = (i / (data.length - 1)) * w; const y = h - ((val - min) / range) * (h - 4) - 2; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.strokeStyle = 'rgba(6, 182, 212, 0.7)'; ctx.lineWidth = 1.5; ctx.stroke(); // Endpoint dot const lastVal = data[data.length - 1]; const lastX = w; const lastY = h - ((lastVal - min) / range) * (h - 4) - 2; ctx.fillStyle = '#22d3ee'; ctx.beginPath(); ctx.arc(lastX - 1, lastY, 2.5, 0, Math.PI * 2); ctx.fill(); } // ---- System Health Simulation ---- function updateSystemHealth() { const metrics = [ { bar: 'cpuBar', val: 'cpuVal', base: 45, range: 30 }, { bar: 'memBar', val: 'memVal', base: 60, range: 20 }, { bar: 'netBar', val: 'netVal', base: 25, range: 35 }, { bar: 'diskBar', val: 'diskVal', base: 55, range: 10 }, ]; metrics.forEach(m => { const value = Math.floor(m.base + Math.random() * m.range); const barEl = document.getElementById(m.bar); const valEl = document.getElementById(m.val); if (barEl) barEl.style.width = `${value}%`; if (valEl) valEl.textContent = `${value}%`; }); } // ---- Scan Simulation ---- function startScan(type) { if (state.scanning) { showToast('Scan already in progress'); return; } const target = document.getElementById('scanTarget'); const targetValue = target && target.value.trim() ? target.value.trim() : 'target.local'; state.scanning = true; const progressDiv = document.getElementById('scanProgress'); const stageEl = document.getElementById('scanStage'); const percentEl = document.getElementById('scanPercent'); const barEl = document.getElementById('scanBar'); const queueEl = document.getElementById('scanQueue'); if (progressDiv) progressDiv.classList.remove('hidden'); state.scanQueue++; if (queueEl) queueEl.textContent = state.scanQueue; const typeNames = { recon: 'Reconnaissance', vuln: 'Vulnerability', dork: 'Google Dork', full: 'Full Audit' }; const stages = { recon: ['Resolving target...', 'Port scanning...', 'Service detection...', 'OS fingerprinting...', 'Generating report...'], vuln: ['Loading vulnerability DB...', 'Testing injection points...', 'Checking misconfigurations...', 'Running exploit checks...', 'Compiling results...'], dork: ['Connecting to Google...', 'Building dork queries...', 'Analyzing results...', 'Checking cached pages...', 'Aggregating findings...'], full: ['Initializing full audit...', 'Phase 1: Recon...', 'Phase 2: Vuln scanning...', 'Phase 3: Dork analysis...', 'Phase 4: Report generation...'], }; const scanStages = stages[type] || stages.recon; let currentStage = 0; let progress = 0; addLog(`Starting ${typeNames[type] || type} scan on ${targetValue}`, 'system'); const interval = setInterval(() => { progress += Math.random() * 8 + 2; if (progress >= 100) progress = 100; const stageIndex = Math.min(Math.floor(progress / 25), scanStages.length - 1); if (stageIndex !== currentStage) { currentStage = stageIndex; addLog(scanStages[currentStage], 'info'); } if (stageEl) stageEl.textContent = scanStages[currentStage]; if (percentEl) percentEl.textContent = `${Math.floor(progress)}%`; if (barEl) barEl.style.width = `${progress}%`; if (progress >= 100) { clearInterval(interval); state.scanning = false; state.scanQueue--; if (queueEl) queueEl.textContent = state.scanQueue; state.scanCount += Math.floor(Math.random() * 5) + 1; animateCounter(document.getElementById('scanCount'), state.scanCount, 500); addLog(`${typeNames[type] || type} scan complete for ${targetValue}`, 'success'); showToast(`${typeNames[type]} scan complete!`); // Add some discoveries from the scan addDiscovery(); addDiscovery(); setTimeout(() => { if (progressDiv) progressDiv.classList.add('hidden'); if (barEl) barEl.style.width = '0%'; }, 2000); } }, 300); } // ---- Operational Mode (Real APIs) ---- let cveFeedInterval = null; async function startOperationalMode() { // System health updates (browser-side metrics) updateSystemHealth(); setInterval(updateSystemHealth, 3000); // Initial logs addLog('SentinelAI Command Center initialized — OPERATIONAL MODE', 'system'); addLog('Real API services: ipinfo.io · NVD CVE · Google DNS', 'success'); addLog('All monitoring subsystems online', 'success'); addLog('GDorks engine loaded — 36 dork templates ready', 'info'); addLog('Network scanner module active — ipinfo.io + DNS resolve', 'info'); // Fetch initial CVE feed from NVD addLog('Fetching real-time CVE data from NVD...', 'system'); await fetchCVeFeed(); // Poll NVD every 60s for new CVEs (respecting rate limits) cveFeedInterval = setInterval(() => fetchCVeFeed(), 60000); // Update threat level based on real data setInterval(updateThreatLevelFromData, 15000); // Resize handler window.addEventListener('resize', () => { if (threatMap) threatMap.invalidateSize(); drawSparkline(); }); } // ---- Fetch real CVE data from NVD ---- async function fetchCVeFeed() { try { const keywords = ['injection', 'xss', 'ransomware', 'zero-day', 'exploit', 'authentication']; const kw = keywords[Math.floor(Math.random() * keywords.length)]; const cves = await SentinelAPI.searchCVEs(kw, 5); if (cves.length === 0) return; cves.forEach(cve => { const timeStr = new Date(cve.published).toTimeString().split(' ')[0]; const sevMap = { critical: 'critical', high: 'high', medium: 'medium', low: 'low' }; const sev = sevMap[cve.severity] || 'medium'; // Add to alerts if high/critical if (sev === 'critical' || sev === 'high') { const alertObj = { severity: sev, message: `${cve.id}: ${cve.description.substring(0, 80)}...`, time: timeStr, icon: sev === 'critical' ? 'shield-alert' : 'alert-triangle', colors: sev === 'critical' ? { bg: 'bg-red-500/10', border: 'border-red-500/20', text: 'text-red-400', badge: 'bg-red-500/20 text-red-400' } : { bg: 'bg-amber-500/10', border: 'border-amber-500/20', text: 'text-amber-400', badge: 'bg-amber-500/20 text-amber-400' }, }; state.alerts.unshift(alertObj); if (state.alerts.length > 20) state.alerts.pop(); } // Add to discoveries state.discoveries.unshift({ type: 'vuln', icon: 'bug', color: sev === 'critical' ? 'red' : sev === 'high' ? 'amber' : 'cyan', msg: `${cve.id} (CVSS ${cve.score}) — ${cve.description.substring(0, 60)}...`, time: timeStr.substring(0, 5), }); if (state.discoveries.length > 15) state.discoveries.pop(); state.threatCount++; addLog(`NVD: ${cve.id} — ${cve.severity.toUpperCase()} CVSS ${cve.score}`, sev === 'critical' ? 'error' : 'warning'); }); renderAlerts(); updateAlertCount(); renderDiscoveries(); animateCounter(document.getElementById('threatCount'), state.threatCount, 500); } catch (e) { addLog('NVD API暂时不可用 — retrying...', 'warning'); } } // ---- Update threat level from real data ---- function updateThreatLevelFromData() { const critCount = state.alerts.filter(a => a.severity === 'critical').length; const highCount = state.alerts.filter(a => a.severity === 'high').length; let level = 'LOW'; if (critCount > 2) level = 'HIGH'; else if (critCount > 0 || highCount > 3) level = 'MEDIUM'; const indicator = document.getElementById('threatLevel'); const textEl = document.getElementById('threatLevelText'); if (indicator && textEl) { textEl.textContent = level; indicator.className = 'threat-indicator flex items-center gap-2 px-3 py-1.5 rounded-lg border'; if (level === 'LOW') { indicator.classList.add('bg-green-500/10', 'border-green-500/20'); textEl.className = 'text-xs font-semibold text-green-400 font-mono'; indicator.querySelector('.w-2').className = 'w-2 h-2 bg-green-400 rounded-full animate-pulse'; } else if (level === 'MEDIUM') { indicator.classList.add('bg-amber-500/10', 'border-amber-500/20'); textEl.className = 'text-xs font-semibold text-amber-400 font-mono'; indicator.querySelector('.w-2').className = 'w-2 h-2 bg-amber-400 rounded-full animate-pulse'; } else { indicator.classList.add('bg-red-500/10', 'border-red-500/20'); textEl.className = 'text-xs font-semibold text-red-400 font-mono'; indicator.querySelector('.w-2').className = 'w-2 h-2 bg-red-400 rounded-full animate-pulse'; } } } // ---- Mobile Menu Toggle ---- function toggleMobileMenu() { const menu = document.getElementById('mobileMenu'); if (menu) menu.classList.toggle('hidden'); } // ---- Toast ---- function showToast(message) { const toast = document.getElementById('ccToast'); const msgEl = document.getElementById('ccToastMsg'); if (!toast || !msgEl) return; msgEl.textContent = message; toast.classList.add('show'); setTimeout(() => toast.classList.remove('show'), 2500); }