Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions api/rss-proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,29 @@ const ALLOWED_DOMAINS = [
'seekingalpha.com',
'www.coindesk.com',
'cointelegraph.com',
// Security advisories — government travel advisory feeds
'travel.state.gov',
'www.smartraveller.gov.au',
'www.safetravel.govt.nz',
// US Embassy security alerts
'th.usembassy.gov',
'ae.usembassy.gov',
'de.usembassy.gov',
'ua.usembassy.gov',
'mx.usembassy.gov',
'in.usembassy.gov',
'pk.usembassy.gov',
'co.usembassy.gov',
'pl.usembassy.gov',
'bd.usembassy.gov',
'it.usembassy.gov',
'do.usembassy.gov',
'mm.usembassy.gov',
// Health advisories
'wwwnc.cdc.gov',
'www.ecdc.europa.eu',
'www.who.int',
'www.afro.who.int',
// Happy variant — positive news sources
'www.goodnewsnetwork.org',
'www.positive.news',
Expand Down
1 change: 1 addition & 0 deletions src/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ export class App {
openCountryStory: (code, name) => this.countryIntel.openCountryStory(code, name),
loadAllData: () => this.dataLoader.loadAllData(),
updateMonitorResults: () => this.dataLoader.updateMonitorResults(),
loadSecurityAdvisories: () => this.dataLoader.loadSecurityAdvisories(),
});

this.eventHandlers = new EventHandlerManager(this.state, {
Expand Down
16 changes: 16 additions & 0 deletions src/app/data-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import { dataFreshness, type DataSourceId } from '@/services/data-freshness';
import { fetchConflictEvents, fetchUcdpClassifications, fetchHapiSummary, fetchUcdpEvents, deduplicateAgainstAcled } from '@/services/conflict';
import { fetchUnhcrPopulation } from '@/services/displacement';
import { fetchClimateAnomalies } from '@/services/climate';
import { fetchSecurityAdvisories } from '@/services/security-advisories';
import { enrichEventsWithExposure } from '@/services/population-exposure';
import { debounce, getCircuitBreakerCooldownInfo } from '@/utils';
import { isFeatureAvailable } from '@/services/runtime-config';
Expand All @@ -95,6 +96,7 @@ import {
PopulationExposurePanel,
TradePolicyPanel,
SupplyChainPanel,
SecurityAdvisoriesPanel,
} from '@/components';
import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel';
import { classifyNewsItem } from '@/services/positive-classifier';
Expand Down Expand Up @@ -1051,6 +1053,9 @@ export class DataLoaderManager implements AppModule {
}
})());

// Security advisories
tasks.push(this.loadSecurityAdvisories());

await Promise.allSettled(tasks);

try {
Expand Down Expand Up @@ -1865,4 +1870,15 @@ export class DataLoaderManager implements AppModule {
// EIA failure does not break the existing World Bank gauge
}
}

async loadSecurityAdvisories(): Promise<void> {
try {
const result = await fetchSecurityAdvisories();
if (result.ok) {
(this.ctx.panels['security-advisories'] as SecurityAdvisoriesPanel)?.setData(result.advisories);
}
} catch (error) {
console.error('[App] Security advisories fetch failed:', error);
}
}
}
8 changes: 8 additions & 0 deletions src/app/panel-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
InvestmentsPanel,
TradePolicyPanel,
SupplyChainPanel,
SecurityAdvisoriesPanel,
} from '@/components';
import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel';
import { PositiveNewsFeedPanel } from '@/components/PositiveNewsFeedPanel';
Expand Down Expand Up @@ -63,6 +64,7 @@ export interface PanelLayoutCallbacks {
openCountryStory: (code: string, name: string) => void;
loadAllData: () => Promise<void>;
updateMonitorResults: () => void;
loadSecurityAdvisories?: () => Promise<void>;
}

export class PanelLayoutManager implements AppModule {
Expand Down Expand Up @@ -539,6 +541,12 @@ export class PanelLayoutManager implements AppModule {

const populationExposurePanel = new PopulationExposurePanel();
this.ctx.panels['population-exposure'] = populationExposurePanel;

const securityAdvisoriesPanel = new SecurityAdvisoriesPanel();
securityAdvisoriesPanel.setRefreshHandler(() => {
void this.callbacks.loadSecurityAdvisories?.();
});
this.ctx.panels['security-advisories'] = securityAdvisoriesPanel;
}

if (SITE_VARIANT === 'finance') {
Expand Down
203 changes: 203 additions & 0 deletions src/components/SecurityAdvisoriesPanel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { Panel } from './Panel';
import { escapeHtml } from '@/utils/sanitize';
import { t } from '@/services/i18n';
import type { SecurityAdvisory } from '@/services/security-advisories';

type AdvisoryFilter = 'all' | 'critical' | 'US' | 'AU' | 'UK' | 'NZ' | 'health';

export class SecurityAdvisoriesPanel extends Panel {
private advisories: SecurityAdvisory[] = [];
private activeFilter: AdvisoryFilter = 'all';
private refreshInterval: ReturnType<typeof setInterval> | null = null;
private onRefreshRequest?: () => void;

constructor() {
super({
id: 'security-advisories',
title: t('panels.securityAdvisories'),
showCount: true,
trackActivity: true,
infoTooltip: t('components.securityAdvisories.infoTooltip'),
});
this.showLoading(t('components.securityAdvisories.loading'));

this.content.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const filterBtn = target.closest<HTMLElement>('.sa-filter');
if (filterBtn) {
this.activeFilter = (filterBtn.dataset.filter || 'all') as AdvisoryFilter;
this.render();
return;
}
if (target.closest('.sa-refresh-btn')) {
this.showLoading(t('components.securityAdvisories.loading'));
this.onRefreshRequest?.();
}
});
}

public setData(advisories: SecurityAdvisory[]): void {
const prevCount = this.advisories.length;
this.advisories = advisories;
this.setCount(advisories.length);

if (prevCount > 0 && advisories.length > prevCount) {
this.setNewBadge(advisories.length - prevCount);
}

this.render();
}

private getFiltered(): SecurityAdvisory[] {
switch (this.activeFilter) {
case 'critical':
return this.advisories.filter(a => a.level === 'do-not-travel' || a.level === 'reconsider');
case 'health':
return this.advisories.filter(a => a.sourceCountry === 'EU' || a.sourceCountry === 'INT');
case 'US':
case 'AU':
case 'UK':
case 'NZ':
return this.advisories.filter(a => a.sourceCountry === this.activeFilter);
default:
return this.advisories;
}
}

private getLevelClass(level?: SecurityAdvisory['level']): string {
switch (level) {
case 'do-not-travel': return 'sa-level-dnt';
case 'reconsider': return 'sa-level-reconsider';
case 'caution': return 'sa-level-caution';
case 'normal': return 'sa-level-normal';
default: return 'sa-level-info';
}
}

private getLevelLabel(level?: SecurityAdvisory['level']): string {
switch (level) {
case 'do-not-travel': return t('components.securityAdvisories.levels.doNotTravel');
case 'reconsider': return t('components.securityAdvisories.levels.reconsider');
case 'caution': return t('components.securityAdvisories.levels.caution');
case 'normal': return t('components.securityAdvisories.levels.normal');
default: return t('components.securityAdvisories.levels.info');
}
}

private getSourceFlag(sourceCountry: string): string {
switch (sourceCountry) {
case 'US': return '\u{1F1FA}\u{1F1F8}';
case 'AU': return '\u{1F1E6}\u{1F1FA}';
case 'UK': return '\u{1F1EC}\u{1F1E7}';
case 'NZ': return '\u{1F1F3}\u{1F1FF}';
case 'EU': return '\u{1F1EA}\u{1F1FA}';
case 'INT': return '\u{1F3E5}';
default: return '\u{1F310}';
}
}

private formatTime(date: Date): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);

if (minutes < 1) return t('components.securityAdvisories.time.justNow');
if (minutes < 60) return t('components.securityAdvisories.time.minutesAgo', { count: String(minutes) });
if (hours < 24) return t('components.securityAdvisories.time.hoursAgo', { count: String(hours) });
if (days < 7) return t('components.securityAdvisories.time.daysAgo', { count: String(days) });
return date.toLocaleDateString();
}

private render(): void {
if (this.advisories.length === 0) {
this.setContent(`<div class="panel-empty">${t('common.noDataAvailable')}</div>`);
return;
}

const filtered = this.getFiltered();

const dntCount = this.advisories.filter(a => a.level === 'do-not-travel').length;
const reconsiderCount = this.advisories.filter(a => a.level === 'reconsider').length;
const cautionCount = this.advisories.filter(a => a.level === 'caution').length;

const summaryHtml = `
<div class="sa-summary">
<div class="sa-summary-item sa-level-dnt">
<span class="sa-summary-count">${dntCount}</span>
<span class="sa-summary-label">${t('components.securityAdvisories.levels.doNotTravel')}</span>
</div>
<div class="sa-summary-item sa-level-reconsider">
<span class="sa-summary-count">${reconsiderCount}</span>
<span class="sa-summary-label">${t('components.securityAdvisories.levels.reconsider')}</span>
</div>
<div class="sa-summary-item sa-level-caution">
<span class="sa-summary-count">${cautionCount}</span>
<span class="sa-summary-label">${t('components.securityAdvisories.levels.caution')}</span>
</div>
</div>
`;

const filtersHtml = `
<div class="sa-filters">
<button class="sa-filter ${this.activeFilter === 'all' ? 'sa-filter-active' : ''}" data-filter="all">${t('common.all')}</button>
<button class="sa-filter ${this.activeFilter === 'critical' ? 'sa-filter-active' : ''}" data-filter="critical">${t('components.securityAdvisories.critical')}</button>
<button class="sa-filter ${this.activeFilter === 'US' ? 'sa-filter-active' : ''}" data-filter="US">\u{1F1FA}\u{1F1F8} US</button>
<button class="sa-filter ${this.activeFilter === 'AU' ? 'sa-filter-active' : ''}" data-filter="AU">\u{1F1E6}\u{1F1FA} AU</button>
<button class="sa-filter ${this.activeFilter === 'UK' ? 'sa-filter-active' : ''}" data-filter="UK">\u{1F1EC}\u{1F1E7} UK</button>
<button class="sa-filter ${this.activeFilter === 'NZ' ? 'sa-filter-active' : ''}" data-filter="NZ">\u{1F1F3}\u{1F1FF} NZ</button>
<button class="sa-filter ${this.activeFilter === 'health' ? 'sa-filter-active' : ''}" data-filter="health">\u{1F3E5} ${t('components.securityAdvisories.health')}</button>
</div>
`;

const displayed = filtered.slice(0, 30);
let itemsHtml: string;

if (displayed.length === 0) {
itemsHtml = `<div class="panel-empty">${t('components.securityAdvisories.noMatching')}</div>`;
} else {
itemsHtml = displayed.map(a => {
const levelCls = this.getLevelClass(a.level);
const levelLabel = this.getLevelLabel(a.level);
const flag = this.getSourceFlag(a.sourceCountry);

return `<div class="sa-item ${levelCls}">
<div class="sa-item-header">
<span class="sa-badge ${levelCls}">${levelLabel}</span>
<span class="sa-source">${flag} ${escapeHtml(a.source)}</span>
</div>
<a href="${escapeHtml(a.link)}" target="_blank" rel="noopener" class="sa-title">${escapeHtml(a.title)}</a>
<div class="sa-time">${this.formatTime(a.pubDate)}</div>
</div>`;
}).join('');
}

const footerHtml = `
<div class="sa-footer">
<span class="sa-footer-source">${t('components.securityAdvisories.sources')}</span>
<button class="sa-refresh-btn">${t('components.securityAdvisories.refresh')}</button>
</div>
`;

this.setContent(`
<div class="sa-panel-content">
${summaryHtml}
${filtersHtml}
<div class="sa-list">${itemsHtml}</div>
${footerHtml}
</div>
`);
}

public setRefreshHandler(handler: () => void): void {
this.onRefreshRequest = handler;
}

public destroy(): void {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
super.destroy();
}
}
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,4 @@ export * from './InvestmentsPanel';
export * from './UnifiedSettings';
export * from './TradePolicyPanel';
export * from './SupplyChainPanel';
export * from './SecurityAdvisoriesPanel';
3 changes: 2 additions & 1 deletion src/config/panels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const FULL_PANELS: Record<string, PanelConfig> = {
displacement: { name: 'UNHCR Displacement', enabled: true, priority: 2 },
climate: { name: 'Climate Anomalies', enabled: true, priority: 2 },
'population-exposure': { name: 'Population Exposure', enabled: true, priority: 2 },
'security-advisories': { name: 'Security Advisories', enabled: true, priority: 2 },
};

const FULL_MAP_LAYERS: MapLayers = {
Expand Down Expand Up @@ -587,7 +588,7 @@ export const PANEL_CATEGORY_MAP: Record<string, { labelKey: string; panelKeys: s
},
dataTracking: {
labelKey: 'header.panelCatDataTracking',
panelKeys: ['monitors', 'satellite-fires', 'ucdp-events', 'displacement', 'climate', 'population-exposure'],
panelKeys: ['monitors', 'satellite-fires', 'ucdp-events', 'displacement', 'climate', 'population-exposure', 'security-advisories'],
variants: ['full'],
},

Expand Down
28 changes: 26 additions & 2 deletions src/locales/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,8 @@
"techReadiness": "مؤشر الجاهزية التقنية",
"gccInvestments": "استثمارات دول الخليج",
"geoHubs": "مراكز جيوسياسية",
"liveWebcams": "كاميرات مباشرة"
"liveWebcams": "كاميرات مباشرة",
"securityAdvisories": "تنبيهات أمنية"
},
"modals": {
"search": {
Expand Down Expand Up @@ -1285,6 +1286,28 @@
"regionAsia": "آسيا",
"regionMiddleEast": "الشرق الأوسط",
"regionAfrica": "أفريقيا"
},
"securityAdvisories": {
"loading": "جاري جلب التحذيرات الأمنية...",
"noMatching": "لا توجد تحذيرات مطابقة لهذا الفلتر",
"critical": "حرج",
"health": "صحة",
"sources": "وزارة الخارجية الأمريكية، DFAT الأسترالية، FCDO البريطانية، MFAT النيوزيلندية, CDC, ECDC, WHO, US Embassies",
"refresh": "تحديث",
"levels": {
"doNotTravel": "لا تسافر",
"reconsider": "أعد التفكير في السفر",
"caution": "توخي الحذر",
"normal": "عادي",
"info": "معلومات"
},
"time": {
"justNow": "الآن",
"minutesAgo": "منذ {{count}} دقيقة",
"hoursAgo": "منذ {{count}} ساعة",
"daysAgo": "منذ {{count}} يوم"
},
"infoTooltip": "<strong>تنبيهات أمنية</strong><br>تحذيرات السفر والتنبيهات الأمنية من الوكالات الحكومية."
}
},
"popups": {
Expand Down Expand Up @@ -1922,6 +1945,7 @@
"close": "إغلاق",
"currentVariant": "(الحالي)",
"retry": "Retry",
"refresh": "Refresh"
"refresh": "Refresh",
"all": "الكل"
}
}
Loading