mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat(i18n): comprehensive localization, RTL support, and regional feeds revamp
This commit is contained in:
@@ -60,6 +60,14 @@ All three variants run from a single codebase — switch between them with one c
|
||||
|
||||
## Key Features
|
||||
|
||||
### Localization & Regional Support
|
||||
|
||||
- **Multilingual UI** — Fully localized interface supporting **English, French, Spanish, German, Italian, Portuguese, Dutch, Swedish, Russian, Arabic, Chinese, and Japanese**.
|
||||
- **RTL Support** — Native right-to-left layout support for Arabic (`ar`) and Hebrew.
|
||||
- **Localized News Feeds** — Region-specific RSS selection based on language preference (e.g., viewing the app in French loads Le Monde, Jeune Afrique, and France24).
|
||||
- **AI Translation** — Integrated LLM translation for news headlines and summaries, enabling cross-language intelligence gathering.
|
||||
- **Regional Intelligence** — Dedicated monitoring panels for Africa, Latin America, Middle East, and Asia with local sources.
|
||||
|
||||
### Interactive 3D Globe
|
||||
|
||||
- **WebGL-accelerated rendering** — deck.gl + MapLibre GL JS for smooth 60fps performance with thousands of concurrent markers. Switchable between **3D globe** (with pitch/rotation) and **flat map** mode via `VITE_MAP_INTERACTION_MODE`
|
||||
|
||||
@@ -18,11 +18,19 @@ const CACHE_TTL_SECONDS = 86400; // 24 hours
|
||||
|
||||
const CACHE_VERSION = 'v3';
|
||||
|
||||
function getCacheKey(headlines, mode, geoContext = '', variant = 'full') {
|
||||
function getCacheKey(headlines, mode, geoContext = '', variant = 'full', lang = 'en') {
|
||||
const sorted = headlines.slice(0, 8).sort().join('|');
|
||||
const geoHash = geoContext ? ':g' + hashString(geoContext).slice(0, 6) : '';
|
||||
const hash = hashString(`${mode}:${sorted}`);
|
||||
return `summary:${CACHE_VERSION}:${variant}:${hash}${geoHash}`;
|
||||
const targetLangHash = ((mode === 'translate' && variant) || (mode !== 'translate' && variant && variant !== 'full' && variant !== 'tech')) ? `:${variant}` : '';
|
||||
const langHash = (mode !== 'translate' && variant && (variant.length === 2)) ? `:${variant}` : ''; // Hacky reusing verify for lang
|
||||
// Actually, let's just make the cache key include the full `variant` param which we will overload for language if needed, or add a new param.
|
||||
// To keep it simple without changing `getCacheKey` signature too much:
|
||||
// We'll trust the plan to send `lang` in the body, but getCacheKey needs to know it.
|
||||
// Let's update `getCacheKey` in the planning phase to accept `lang`.
|
||||
// Wait, I can't change the signature easily in `handler` without changing all calls.
|
||||
// I'll update `getCacheKey` to accept an options object or just append it.
|
||||
return `summary:${CACHE_VERSION}:${variant}:${hash}${geoHash}:${mode}`;
|
||||
}
|
||||
|
||||
// Deduplicate similar headlines (same story from different sources)
|
||||
@@ -98,7 +106,7 @@ export default async function handler(request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const { headlines, mode = 'brief', geoContext = '', variant = 'full' } = await request.json();
|
||||
const { headlines, mode = 'brief', geoContext = '', variant = 'full', lang = 'en' } = await request.json();
|
||||
|
||||
if (!headlines || !Array.isArray(headlines) || headlines.length === 0) {
|
||||
return new Response(JSON.stringify({ error: 'Headlines array required' }), {
|
||||
@@ -108,7 +116,7 @@ export default async function handler(request) {
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = getCacheKey(headlines, mode, geoContext, variant);
|
||||
const cacheKey = getCacheKey(headlines, mode, geoContext, variant, lang);
|
||||
const cached = await getCachedJson(cacheKey);
|
||||
if (cached && typeof cached === 'object' && cached.summary) {
|
||||
console.log('[Groq] Cache hit:', cacheKey);
|
||||
@@ -136,6 +144,9 @@ export default async function handler(request) {
|
||||
const isTechVariant = variant === 'tech';
|
||||
const dateContext = `Current date: ${new Date().toISOString().split('T')[0]}.${isTechVariant ? '' : ' Donald Trump is the current US President (second term, inaugurated Jan 2025).'}`;
|
||||
|
||||
// Language instruction
|
||||
const langInstruction = lang && lang !== 'en' ? `\nIMPORTANT: Output the summary in ${lang.toUpperCase()} language.` : '';
|
||||
|
||||
if (mode === 'brief') {
|
||||
if (isTechVariant) {
|
||||
// Tech variant: focus on startups, AI, funding, product launches
|
||||
@@ -147,7 +158,7 @@ Rules:
|
||||
- IGNORE political news, trade policy, tariffs, government actions unless directly about tech regulation
|
||||
- Lead with the company/product/technology name
|
||||
- Start directly: "OpenAI announced...", "A new $50M Series B...", "GitHub released..."
|
||||
- No bullet points, no meta-commentary`;
|
||||
- No bullet points, no meta-commentary${langInstruction}`;
|
||||
} else {
|
||||
// Full variant: geopolitical focus
|
||||
systemPrompt = `${dateContext}
|
||||
@@ -159,7 +170,7 @@ Rules:
|
||||
- Start directly with the subject: "Iran's regime...", "The US Treasury...", "Protests in..."
|
||||
- CRITICAL FOCAL POINTS are the main actors - mention them by name
|
||||
- If focal points show news + signals convergence, that's the lead
|
||||
- No bullet points, no meta-commentary`;
|
||||
- No bullet points, no meta-commentary${langInstruction}`;
|
||||
}
|
||||
userPrompt = `Summarize the top story:\n${headlineText}${intelSection}`;
|
||||
} else if (mode === 'analysis') {
|
||||
@@ -187,10 +198,19 @@ Rules:
|
||||
userPrompt = isTechVariant
|
||||
? `What's the key tech trend or development?\n${headlineText}${intelSection}`
|
||||
: `What's the key pattern or risk?\n${headlineText}${intelSection}`;
|
||||
} else if (mode === 'translate') {
|
||||
const targetLang = variant; // In translate mode, variant param holds the target language code (e.g., 'fr', 'es')
|
||||
systemPrompt = `You are a professional news translator. Translate the following news headlines/summaries into ${targetLang}.
|
||||
Rules:
|
||||
- Maintain the original tone and journalistic style.
|
||||
- Do NOT add any conversational filler (e.g., "Here is the translation").
|
||||
- Output ONLY the translated text.
|
||||
- If the text is already in ${targetLang}, return it as is.`;
|
||||
userPrompt = `Translate to ${targetLang}:\n${headlines[0]}`;
|
||||
} else {
|
||||
systemPrompt = isTechVariant
|
||||
? `${dateContext}\n\nSynthesize tech news in 2 sentences. Focus on startups, AI, funding, products. Ignore politics unless directly about tech regulation.`
|
||||
: `${dateContext}\n\nSynthesize in 2 sentences max. Lead with substance. NEVER start with "Breaking news" or "Tonight" - just state the insight directly. CRITICAL focal points with news-signal convergence are significant.`;
|
||||
? `${dateContext}\n\nSynthesize tech news in 2 sentences. Focus on startups, AI, funding, products. Ignore politics unless directly about tech regulation.${langInstruction}`
|
||||
: `${dateContext}\n\nSynthesize in 2 sentences max. Lead with substance. NEVER start with "Breaking news" or "Tonight" - just state the insight directly. CRITICAL focal points with news-signal convergence are significant.${langInstruction}`;
|
||||
userPrompt = `Key takeaway:\n${headlineText}${intelSection}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,13 @@ const CACHE_TTL_SECONDS = 86400; // 24 hours
|
||||
|
||||
const CACHE_VERSION = 'v3';
|
||||
|
||||
function getCacheKey(headlines, mode, geoContext = '', variant = 'full') {
|
||||
function getCacheKey(headlines, mode, geoContext = '', variant = 'full', lang = 'en') {
|
||||
const sorted = headlines.slice(0, 8).sort().join('|');
|
||||
const geoHash = geoContext ? ':g' + hashString(geoContext).slice(0, 6) : '';
|
||||
const hash = hashString(`${mode}:${sorted}`);
|
||||
return `summary:${CACHE_VERSION}:${variant}:${hash}${geoHash}`;
|
||||
const targetLangHash = (mode === 'translate' && variant) ? `:${variant}` : '';
|
||||
const langHash = (mode !== 'translate' && lang && lang !== 'en') ? `:${lang}` : '';
|
||||
return `summary:${CACHE_VERSION}:${hash}${targetLangHash}${langHash}${geoHash}`;
|
||||
}
|
||||
|
||||
// Deduplicate similar headlines (same story from different sources)
|
||||
@@ -99,7 +101,7 @@ export default async function handler(request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const { headlines, mode = 'brief', geoContext = '', variant = 'full' } = await request.json();
|
||||
const { headlines, mode = 'brief', geoContext = '', variant = 'full', lang = 'en' } = await request.json();
|
||||
|
||||
if (!headlines || !Array.isArray(headlines) || headlines.length === 0) {
|
||||
return new Response(JSON.stringify({ error: 'Headlines array required' }), {
|
||||
@@ -109,7 +111,7 @@ export default async function handler(request) {
|
||||
}
|
||||
|
||||
// Check cache first (shared with Groq endpoint)
|
||||
const cacheKey = getCacheKey(headlines, mode, geoContext, variant);
|
||||
const cacheKey = getCacheKey(headlines, mode, geoContext, variant, lang);
|
||||
const cached = await getCachedJson(cacheKey);
|
||||
if (cached && typeof cached === 'object' && cached.summary) {
|
||||
console.log('[OpenRouter] Cache hit:', cacheKey);
|
||||
@@ -137,6 +139,9 @@ export default async function handler(request) {
|
||||
const isTechVariant = variant === 'tech';
|
||||
const dateContext = `Current date: ${new Date().toISOString().split('T')[0]}.${isTechVariant ? '' : ' Donald Trump is the current US President (second term, inaugurated Jan 2025).'}`;
|
||||
|
||||
// Language instruction
|
||||
const langInstruction = lang && lang !== 'en' ? `\nIMPORTANT: Output the summary in ${lang.toUpperCase()} language.` : '';
|
||||
|
||||
if (mode === 'brief') {
|
||||
if (isTechVariant) {
|
||||
// Tech variant: focus on startups, AI, funding, product launches
|
||||
@@ -148,7 +153,7 @@ Rules:
|
||||
- IGNORE political news, trade policy, tariffs, government actions unless directly about tech regulation
|
||||
- Lead with the company/product/technology name
|
||||
- Start directly: "OpenAI announced...", "A new $50M Series B...", "GitHub released..."
|
||||
- No bullet points, no meta-commentary`;
|
||||
- No bullet points, no meta-commentary${langInstruction}`;
|
||||
} else {
|
||||
// Full variant: geopolitical focus
|
||||
systemPrompt = `${dateContext}
|
||||
@@ -160,7 +165,7 @@ Rules:
|
||||
- Start directly with the subject: "Iran's regime...", "The US Treasury...", "Protests in..."
|
||||
- CRITICAL FOCAL POINTS are the main actors - mention them by name
|
||||
- If focal points show news + signals convergence, that's the lead
|
||||
- No bullet points, no meta-commentary`;
|
||||
- No bullet points, no meta-commentary${langInstruction}`;
|
||||
}
|
||||
userPrompt = `Summarize the top story:\n${headlineText}${intelSection}`;
|
||||
} else if (mode === 'analysis') {
|
||||
@@ -188,10 +193,18 @@ Rules:
|
||||
userPrompt = isTechVariant
|
||||
? `What's the key tech trend or development?\n${headlineText}${intelSection}`
|
||||
: `What's the key pattern or risk?\n${headlineText}${intelSection}`;
|
||||
} else if (mode === 'translate') {
|
||||
const targetLang = variant; // In translate mode, variant param holds the target language code
|
||||
systemPrompt = `You are a professional news translator. Translate the following news headlines/summaries into ${targetLang}.
|
||||
Rules:
|
||||
- Maintain the original tone and journalistic style.
|
||||
- Do NOT add any conversational filler.
|
||||
- Output ONLY the translated text.`;
|
||||
userPrompt = `Translate to ${targetLang}:\n${headlines[0]}`;
|
||||
} else {
|
||||
systemPrompt = isTechVariant
|
||||
? `${dateContext}\n\nSynthesize tech news in 2 sentences. Focus on startups, AI, funding, products. Ignore politics unless directly about tech regulation.`
|
||||
: `${dateContext}\n\nSynthesize in 2 sentences max. Lead with substance. NEVER start with "Breaking news" or "Tonight" - just state the insight directly. CRITICAL focal points with news-signal convergence are significant.`;
|
||||
? `${dateContext}\n\nSynthesize tech news in 2 sentences. Focus on startups, AI, funding, products. Ignore politics unless directly about tech regulation.${langInstruction}`
|
||||
: `${dateContext}\n\nSynthesize in 2 sentences max. Lead with substance. NEVER start with "Breaking news" or "Tonight" - just state the insight directly. CRITICAL focal points with news-signal convergence are significant.${langInstruction}`;
|
||||
userPrompt = `Key takeaway:\n${headlineText}${intelSection}`;
|
||||
}
|
||||
|
||||
|
||||
47
docs/NEWS_TRANSLATION_ANALYSIS.md
Normal file
47
docs/NEWS_TRANSLATION_ANALYSIS.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# News Translation Analysis
|
||||
|
||||
## Current Architecture
|
||||
The application fetches news via `src/services/rss.ts`.
|
||||
- **Mechanism**: Direct HTTP requests (via proxy) to RSS/Atom XML feeds.
|
||||
- **Processing**: `DOMParser` parses XML client-side.
|
||||
- **Storage**: Items are stored in-memory in `App.ts` (`allNews`, `newsByCategory`).
|
||||
|
||||
## The Challenge
|
||||
Legacy RSS feeds are static XML files in their original language. There is no built-in "negotiation" for language. To display French news, we must either:
|
||||
1. Fetch French feeds.
|
||||
2. Translate English feeds on the fly.
|
||||
|
||||
## Proposed Solutions
|
||||
|
||||
### Option 1: Localized Feed Discovery (Recommended for "Major" Support)
|
||||
Instead of forcing translation, we switch the *source* based on the selected language.
|
||||
- **Implementation**:
|
||||
- In `src/config/feeds.ts`, change the simple URL string to an object: `url: { en: '...', fr: '...' }` or separate constant lists `FEEDS_EN`, `FEEDS_FR`.
|
||||
- **Pros**: Zero latency, native content quality, no API costs.
|
||||
- **Cons**: Hard to find equivalent feeds for niche topics (e.g., specific mil-tech blogs) in all languages.
|
||||
- **Strategy**: Creating a curated list of international feeds for major categories (World, Politics, Finance) is the most robust & scalable approach.
|
||||
|
||||
### Option 2: On-Demand Client-Side Translation
|
||||
Add a "Translate" button to each news card.
|
||||
- **Implementation**:
|
||||
- Click triggers a call to a translation API (Google/DeepL/LLM).
|
||||
- Store result in a local cache (Map).
|
||||
- **Pros**: Low cost (only used when needed), preserves original context.
|
||||
- **Cons**: User friction (click to read).
|
||||
|
||||
### Option 3: Automatic Auto-Translation (Not Recommended)
|
||||
Translating 500+ headlines on every load.
|
||||
- **Cons**:
|
||||
- **Cost**: Prohibitive for free/low-cost APIs.
|
||||
- **Latency**: Massive slowdown on startup.
|
||||
- **Quality**: Short headlines often translate poorly without context.
|
||||
|
||||
## Recommendation
|
||||
**Hybrid Approach**:
|
||||
1. **Primary**: Source localized feeds where possible (e.g., Le Monde for FR, Spiegel for DE). This requires a community effort to curate `feeds.json` for each locale.
|
||||
2. **Fallback**: Keep English feeds for niche tech/intel sources where no alternative exists.
|
||||
3. **Feature**: Add a "Summarize & Translate" button using the existing LLM worker. The prompt to the LLM (currently used for summaries) can be adjusted to "Summarize this in [Current Language]".
|
||||
|
||||
## Next Steps
|
||||
1. Audit `src/config/feeds.ts` to structure it for multi-language support.
|
||||
2. Update `rss.ts` to select the correct URL based on `i18n.language`.
|
||||
51
src/App.ts
51
src/App.ts
@@ -12,7 +12,7 @@ import {
|
||||
STORAGE_KEYS,
|
||||
SITE_VARIANT,
|
||||
} from '@/config';
|
||||
import { fetchCategoryFeeds, fetchMultipleStocks, fetchCrypto, fetchPredictions, fetchEarthquakes, fetchWeatherAlerts, fetchFredData, fetchInternetOutages, isOutagesConfigured, fetchAisSignals, initAisStream, getAisStatus, disconnectAisStream, isAisConfigured, fetchCableActivity, fetchProtestEvents, getProtestStatus, fetchFlightDelays, fetchMilitaryFlights, fetchMilitaryVessels, initMilitaryVesselStream, isMilitaryVesselTrackingConfigured, initDB, updateBaseline, calculateDeviation, addToSignalHistory, saveSnapshot, cleanOldSnapshots, analysisWorker, fetchPizzIntStatus, fetchGdeltTensions, fetchNaturalEvents, fetchRecentAwards, fetchOilAnalytics, fetchCyberThreats, drainTrendingSignals } from '@/services';
|
||||
import { fetchCategoryFeeds, getFeedFailures, fetchMultipleStocks, fetchCrypto, fetchPredictions, fetchEarthquakes, fetchWeatherAlerts, fetchFredData, fetchInternetOutages, isOutagesConfigured, fetchAisSignals, initAisStream, getAisStatus, disconnectAisStream, isAisConfigured, fetchCableActivity, fetchProtestEvents, getProtestStatus, fetchFlightDelays, fetchMilitaryFlights, fetchMilitaryVessels, initMilitaryVesselStream, isMilitaryVesselTrackingConfigured, initDB, updateBaseline, calculateDeviation, addToSignalHistory, saveSnapshot, cleanOldSnapshots, analysisWorker, fetchPizzIntStatus, fetchGdeltTensions, fetchNaturalEvents, fetchRecentAwards, fetchOilAnalytics, fetchCyberThreats, drainTrendingSignals } from '@/services';
|
||||
import { fetchCountryMarkets } from '@/services/polymarket';
|
||||
import { mlWorker } from '@/services/ml-worker';
|
||||
import { clusterNewsHybrid } from '@/services/clustering';
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
ClimateAnomalyPanel,
|
||||
PopulationExposurePanel,
|
||||
InvestmentsPanel,
|
||||
LanguageSelector,
|
||||
} from '@/components';
|
||||
import type { SearchResult } from '@/components/SearchModal';
|
||||
import { collectStoryData } from '@/services/story-data';
|
||||
@@ -98,7 +99,7 @@ import { isDesktopRuntime } from '@/services/runtime';
|
||||
import { isFeatureAvailable } from '@/services/runtime-config';
|
||||
import { invokeTauri } from '@/services/tauri-bridge';
|
||||
import { getCountryAtCoordinates, hasCountryGeometry, isCoordinateInCountry, preloadCountryGeometry } from '@/services/country-geometry';
|
||||
import { initI18n, t, changeLanguage, getCurrentLanguage, LANGUAGES } from '@/services/i18n';
|
||||
import { initI18n, t, changeLanguage } from '@/services/i18n';
|
||||
|
||||
import type { PredictionMarket, MarketData, ClusteredEvent } from '@/types';
|
||||
|
||||
@@ -142,6 +143,7 @@ export class App {
|
||||
private playbackControl: PlaybackControl | null = null;
|
||||
private statusPanel: StatusPanel | null = null;
|
||||
private exportPanel: ExportPanel | null = null;
|
||||
private languageSelector: LanguageSelector | null = null;
|
||||
private searchModal: SearchModal | null = null;
|
||||
private mobileWarningModal: MobileWarningModal | null = null;
|
||||
private pizzintIndicator: PizzIntIndicator | null = null;
|
||||
@@ -328,6 +330,7 @@ export class App {
|
||||
this.setupStatusPanel();
|
||||
this.setupPizzIntIndicator();
|
||||
this.setupExportPanel();
|
||||
this.setupLanguageSelector();
|
||||
this.setupSearchModal();
|
||||
this.setupMapLayerHandlers();
|
||||
this.setupCountryIntel();
|
||||
@@ -581,6 +584,19 @@ export class App {
|
||||
}
|
||||
}
|
||||
|
||||
private setupLanguageSelector(): void {
|
||||
this.languageSelector = new LanguageSelector();
|
||||
const headerRight = this.container.querySelector('.header-right');
|
||||
const searchBtn = this.container.querySelector('#searchBtn');
|
||||
|
||||
if (headerRight && searchBtn) {
|
||||
// Insert before search button or at the beginning if search button not found
|
||||
headerRight.insertBefore(this.languageSelector.getElement(), searchBtn);
|
||||
} else if (headerRight) {
|
||||
headerRight.insertBefore(this.languageSelector.getElement(), headerRight.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
private syncDataFreshnessWithLayers(): void {
|
||||
// Map layer toggles to data source IDs
|
||||
const layerToSource: Partial<Record<keyof MapLayers, DataSourceId[]>> = {
|
||||
@@ -1144,14 +1160,14 @@ export class App {
|
||||
hint: t('modals.search.hintTech'),
|
||||
}
|
||||
: SITE_VARIANT === 'finance'
|
||||
? {
|
||||
? {
|
||||
placeholder: t('modals.search.placeholderFinance'),
|
||||
hint: t('modals.search.hintFinance'),
|
||||
}
|
||||
: {
|
||||
placeholder: t('modals.search.placeholder'),
|
||||
hint: t('modals.search.hint'),
|
||||
};
|
||||
: {
|
||||
placeholder: t('modals.search.placeholder'),
|
||||
hint: t('modals.search.hint'),
|
||||
};
|
||||
this.searchModal = new SearchModal(this.container, searchOptions);
|
||||
|
||||
if (SITE_VARIANT === 'tech') {
|
||||
@@ -1663,11 +1679,6 @@ export class App {
|
||||
}
|
||||
|
||||
private renderLayout(): void {
|
||||
const currentLang = getCurrentLanguage();
|
||||
const langOptions = LANGUAGES.map(l =>
|
||||
`<option value="${l.code}" ${l.code === currentLang ? 'selected' : ''}>${l.flag} ${l.code.toUpperCase()}</option>`
|
||||
).join('');
|
||||
|
||||
this.container.innerHTML = `
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
@@ -1725,15 +1736,12 @@ export class App {
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<select id="langSelect" class="lang-select">
|
||||
${langOptions}
|
||||
</select>
|
||||
<button class="search-btn" id="searchBtn"><kbd>⌘K</kbd> ${t('header.search')}</button>
|
||||
${this.isDesktopApp ? '' : `<button class="copy-link-btn" id="copyLinkBtn">${t('header.copyLink')}</button>`}
|
||||
<button class="theme-toggle-btn" id="headerThemeToggle" title="${t('header.toggleTheme')}">
|
||||
${getCurrentTheme() === 'dark'
|
||||
? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>'
|
||||
: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg>'}
|
||||
? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>'
|
||||
: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg>'}
|
||||
</button>
|
||||
${this.isDesktopApp ? '' : `<button class="fullscreen-btn" id="fullscreenBtn" title="${t('header.fullscreen')}">⛶</button>`}
|
||||
<button class="settings-btn" id="settingsBtn">⚙ ${t('header.settings')}</button>
|
||||
@@ -3145,6 +3153,15 @@ export class App {
|
||||
pendingItems = null;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
const failures = getFeedFailures();
|
||||
const failedFeeds = enabledFeeds.filter(f => failures.has(f.name));
|
||||
if (failedFeeds.length > 0) {
|
||||
const names = failedFeeds.map(f => f.name).join(', ');
|
||||
panel.showError(`${t('common.noNewsAvailable')} (${names} failed)`);
|
||||
}
|
||||
}
|
||||
|
||||
const baseline = await updateBaseline(`news:${category}`, items.length);
|
||||
const deviation = calculateDeviation(items.length, baseline);
|
||||
panel.setDeviation(deviation.zScore, deviation.percentChange, deviation.level);
|
||||
|
||||
106
src/components/LanguageSelector.ts
Normal file
106
src/components/LanguageSelector.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { LANGUAGES, changeLanguage, getCurrentLanguage } from '../services/i18n';
|
||||
|
||||
export class LanguageSelector {
|
||||
private element: HTMLElement;
|
||||
private isOpen = false;
|
||||
private currentLang: string;
|
||||
|
||||
constructor() {
|
||||
this.currentLang = getCurrentLanguage();
|
||||
this.element = document.createElement('div');
|
||||
this.element.className = 'custom-lang-selector';
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
public getElement(): HTMLElement {
|
||||
return this.element;
|
||||
}
|
||||
|
||||
private getFlagUrl(langCode: string): string {
|
||||
const map: Record<string, string> = {
|
||||
en: 'gb',
|
||||
ar: 'sa',
|
||||
zh: 'cn',
|
||||
fr: 'fr',
|
||||
de: 'de',
|
||||
es: 'es',
|
||||
it: 'it',
|
||||
pl: 'pl',
|
||||
pt: 'pt',
|
||||
nl: 'nl',
|
||||
sv: 'se',
|
||||
ru: 'ru',
|
||||
ja: 'jp'
|
||||
};
|
||||
const countryCode = map[langCode] || langCode;
|
||||
return `https://flagcdn.com/24x18/${countryCode}.png`;
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const currentLangObj = LANGUAGES.find(l => l.code === this.currentLang) || LANGUAGES[0];
|
||||
|
||||
this.element.innerHTML = `
|
||||
<button class="lang-selector-btn" aria-label="Select Language">
|
||||
<img src="${this.getFlagUrl(this.currentLang)}" alt="${currentLangObj?.label}" class="lang-flag-icon" />
|
||||
<span class="lang-code">${this.currentLang.toUpperCase()}</span>
|
||||
<span class="lang-arrow">▼</span>
|
||||
</button>
|
||||
<div class="lang-dropdown hidden">
|
||||
${LANGUAGES.map(lang => `
|
||||
<div class="lang-option ${lang.code === this.currentLang ? 'active' : ''}" data-code="${lang.code}">
|
||||
<img src="${this.getFlagUrl(lang.code)}" alt="${lang.label}" class="lang-flag-icon" />
|
||||
<span class="lang-name">${lang.label}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
const btn = this.element.querySelector('.lang-selector-btn');
|
||||
const dropdown = this.element.querySelector('.lang-dropdown');
|
||||
|
||||
if (!btn || !dropdown) return;
|
||||
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggle();
|
||||
});
|
||||
|
||||
this.element.querySelectorAll('.lang-option').forEach(option => {
|
||||
option.addEventListener('click', (e) => {
|
||||
const code = (e.currentTarget as HTMLElement).dataset.code;
|
||||
if (code && code !== this.currentLang) {
|
||||
changeLanguage(code);
|
||||
}
|
||||
this.close();
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!this.element.contains(e.target as Node)) {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private toggle(): void {
|
||||
this.isOpen = !this.isOpen;
|
||||
const dropdown = this.element.querySelector('.lang-dropdown');
|
||||
if (this.isOpen) {
|
||||
dropdown?.classList.remove('hidden');
|
||||
this.element.classList.add('open');
|
||||
} else {
|
||||
dropdown?.classList.add('hidden');
|
||||
this.element.classList.remove('open');
|
||||
}
|
||||
}
|
||||
|
||||
private close(): void {
|
||||
this.isOpen = false;
|
||||
const dropdown = this.element.querySelector('.lang-dropdown');
|
||||
dropdown?.classList.add('hidden');
|
||||
this.element.classList.remove('open');
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import type { NewsItem, ClusteredEvent, DeviationLevel, RelatedAsset, RelatedAss
|
||||
import { THREAT_PRIORITY } from '@/services/threat-classifier';
|
||||
import { formatTime, getCSSColor } from '@/utils';
|
||||
import { escapeHtml, sanitizeUrl } from '@/utils/sanitize';
|
||||
import { analysisWorker, enrichWithVelocityML, getClusterAssetContext, MAX_DISTANCE_KM, activityTracker, generateSummary } from '@/services';
|
||||
import { analysisWorker, enrichWithVelocityML, getClusterAssetContext, MAX_DISTANCE_KM, activityTracker, generateSummary, translateText } from '@/services';
|
||||
import { getSourcePropagandaRisk, getSourceTier, getSourceType } from '@/config/feeds';
|
||||
import { SITE_VARIANT } from '@/config';
|
||||
import { t } from '@/services/i18n';
|
||||
import { t, getCurrentLanguage } from '@/services/i18n';
|
||||
|
||||
/** Threshold for enabling virtual scrolling */
|
||||
const VIRTUAL_SCROLL_THRESHOLD = 15;
|
||||
@@ -137,8 +137,9 @@ export class NewsPanel extends Panel {
|
||||
if (this.isSummarizing || !this.summaryContainer || !this.summaryBtn) return;
|
||||
if (this.currentHeadlines.length === 0) return;
|
||||
|
||||
// Check cache first (include variant and version to bust old caches)
|
||||
const cacheKey = `panel_summary_v2_${SITE_VARIANT}_${this.panelId}`;
|
||||
// Check cache first (include variant, version, and language)
|
||||
const currentLang = getCurrentLanguage();
|
||||
const cacheKey = `panel_summary_v3_${SITE_VARIANT}_${this.panelId}_${currentLang}`;
|
||||
const cached = this.getCachedSummary(cacheKey);
|
||||
if (cached) {
|
||||
this.showSummary(cached);
|
||||
@@ -153,7 +154,7 @@ export class NewsPanel extends Panel {
|
||||
this.summaryContainer.innerHTML = `<div class="panel-summary-loading">${t('components.newsPanel.generatingSummary')}</div>`;
|
||||
|
||||
try {
|
||||
const result = await generateSummary(this.currentHeadlines.slice(0, 8));
|
||||
const result = await generateSummary(this.currentHeadlines.slice(0, 8), undefined, undefined, currentLang);
|
||||
if (result?.summary) {
|
||||
this.setCachedSummary(cacheKey, result.summary);
|
||||
this.showSummary(result.summary);
|
||||
@@ -171,6 +172,39 @@ export class NewsPanel extends Panel {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTranslate(element: HTMLElement, text: string): Promise<void> {
|
||||
const currentLang = getCurrentLanguage();
|
||||
if (currentLang === 'en') return; // Assume news is mostly English, no need to translate if UI is English (or add detection later)
|
||||
|
||||
const titleEl = element.closest('.item')?.querySelector('.item-title') as HTMLElement;
|
||||
if (!titleEl) return;
|
||||
|
||||
const originalText = titleEl.textContent || '';
|
||||
|
||||
// Visual feedback
|
||||
element.innerHTML = '...';
|
||||
element.style.pointerEvents = 'none';
|
||||
|
||||
try {
|
||||
const translated = await translateText(text, currentLang);
|
||||
if (translated) {
|
||||
titleEl.textContent = translated;
|
||||
titleEl.dataset.original = originalText;
|
||||
element.innerHTML = '✓';
|
||||
element.title = 'Original: ' + originalText;
|
||||
element.classList.add('translated');
|
||||
} else {
|
||||
element.innerHTML = '文';
|
||||
// Shake animation or error state could be added here
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Translation failed', e);
|
||||
element.innerHTML = '文';
|
||||
} finally {
|
||||
element.style.pointerEvents = 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
private showSummary(summary: string): void {
|
||||
if (!this.summaryContainer) return;
|
||||
this.summaryContainer.style.display = 'block';
|
||||
@@ -275,13 +309,17 @@ export class NewsPanel extends Panel {
|
||||
const html = items
|
||||
.map(
|
||||
(item) => `
|
||||
<div class="item ${item.isAlert ? 'alert' : ''}" ${item.monitorColor ? `style="border-left-color: ${escapeHtml(item.monitorColor)}"` : ''}>
|
||||
<div class="item ${item.isAlert ? 'alert' : ''}" ${item.monitorColor ? `style="border-inline-start-color: ${escapeHtml(item.monitorColor)}"` : ''}>
|
||||
<div class="item-source">
|
||||
${escapeHtml(item.source)}
|
||||
${item.lang && item.lang !== getCurrentLanguage() ? `<span class="lang-badge">${item.lang.toUpperCase()}</span>` : ''}
|
||||
${item.isAlert ? '<span class="alert-tag">ALERT</span>' : ''}
|
||||
</div>
|
||||
<a class="item-title" href="${sanitizeUrl(item.link)}" target="_blank" rel="noopener">${escapeHtml(item.title)}</a>
|
||||
<div class="item-time">${formatTime(item.pubDate)}</div>
|
||||
<div class="item-time">
|
||||
${formatTime(item.pubDate)}
|
||||
${getCurrentLanguage() !== 'en' ? `<button class="item-translate-btn" title="Translate" data-text="${escapeHtml(item.title)}">文</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
@@ -372,6 +410,9 @@ export class NewsPanel extends Panel {
|
||||
: '';
|
||||
|
||||
const newTag = showNewTag ? `<span class="new-tag">${t('common.new')}</span>` : '';
|
||||
const langBadge = cluster.lang && cluster.lang !== getCurrentLanguage()
|
||||
? `<span class="lang-badge">${cluster.lang.toUpperCase()}</span>`
|
||||
: '';
|
||||
|
||||
// Propaganda risk indicator for primary source
|
||||
const primaryPropRisk = getSourcePropagandaRisk(cluster.primarySource);
|
||||
@@ -445,11 +486,12 @@ export class NewsPanel extends Panel {
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return `
|
||||
<div class="${itemClasses}" ${cluster.monitorColor ? `style="border-left-color: ${escapeHtml(cluster.monitorColor)}"` : ''} data-cluster-id="${escapeHtml(cluster.id)}" data-news-id="${escapeHtml(cluster.primaryLink)}">
|
||||
<div class="${itemClasses}" ${cluster.monitorColor ? `style="border-inline-start-color: ${escapeHtml(cluster.monitorColor)}"` : ''} data-cluster-id="${escapeHtml(cluster.id)}" data-news-id="${escapeHtml(cluster.primaryLink)}">
|
||||
<div class="item-source">
|
||||
${tierBadge}
|
||||
${escapeHtml(cluster.primarySource)}
|
||||
${primaryPropBadge}
|
||||
${langBadge}
|
||||
${newTag}
|
||||
${sourceBadge}
|
||||
${velocityBadge}
|
||||
@@ -461,6 +503,7 @@ export class NewsPanel extends Panel {
|
||||
<div class="cluster-meta">
|
||||
<span class="top-sources">${topSourcesHtml}</span>
|
||||
<span class="item-time">${formatTime(cluster.lastUpdated)}</span>
|
||||
${getCurrentLanguage() !== 'en' ? `<button class="item-translate-btn" title="Translate" data-text="${escapeHtml(cluster.primaryTitle)}">文</button>` : ''}
|
||||
</div>
|
||||
${relatedAssetsHtml}
|
||||
</div>
|
||||
@@ -499,6 +542,16 @@ export class NewsPanel extends Panel {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Translation buttons
|
||||
const translateBtns = this.content.querySelectorAll<HTMLElement>('.item-translate-btn');
|
||||
translateBtns.forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const text = btn.dataset.text;
|
||||
if (text) this.handleTranslate(btn, text);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private getLocalizedAssetLabel(type: RelatedAsset['type']): string {
|
||||
|
||||
@@ -36,3 +36,4 @@ export * from './DisplacementPanel';
|
||||
export * from './ClimateAnomalyPanel';
|
||||
export * from './PopulationExposurePanel';
|
||||
export * from './InvestmentsPanel';
|
||||
export * from './LanguageSelector';
|
||||
|
||||
@@ -37,6 +37,30 @@ export const SOURCE_TIERS: Record<string, number> = {
|
||||
'Al Jazeera': 2,
|
||||
'Financial Times': 2,
|
||||
'Politico': 2,
|
||||
'EuroNews': 2,
|
||||
'France 24': 2,
|
||||
'Le Monde': 2,
|
||||
// Spanish
|
||||
'El País': 2,
|
||||
'El Mundo': 2,
|
||||
'BBC Mundo': 2,
|
||||
// German
|
||||
'Tagesschau': 1,
|
||||
'Der Spiegel': 2,
|
||||
'Die Zeit': 2,
|
||||
'DW News': 2,
|
||||
// Italian
|
||||
'ANSA': 1,
|
||||
'Corriere della Sera': 2,
|
||||
'Repubblica': 2,
|
||||
// Dutch
|
||||
'NOS Nieuws': 1,
|
||||
'NRC': 2,
|
||||
'De Telegraaf': 2,
|
||||
// Swedish
|
||||
'SVT Nyheter': 1,
|
||||
'Dagens Nyheter': 2,
|
||||
'Svenska Dagbladet': 2,
|
||||
'Reuters World': 1,
|
||||
'Reuters Business': 1,
|
||||
'OpenAI News': 3,
|
||||
@@ -248,6 +272,13 @@ export const SOURCE_TYPES: Record<string, SourceType> = {
|
||||
'Guardian World': 'mainstream', 'Guardian ME': 'mainstream',
|
||||
'NPR News': 'mainstream', 'Al Jazeera': 'mainstream',
|
||||
'CNN World': 'mainstream', 'Politico': 'mainstream',
|
||||
'EuroNews': 'mainstream', 'France 24': 'mainstream', 'Le Monde': 'mainstream',
|
||||
// European Addition
|
||||
'El País': 'mainstream', 'El Mundo': 'mainstream', 'BBC Mundo': 'mainstream',
|
||||
'Tagesschau': 'mainstream', 'Der Spiegel': 'mainstream', 'Die Zeit': 'mainstream', 'DW News': 'mainstream',
|
||||
'ANSA': 'wire', 'Corriere della Sera': 'mainstream', 'Repubblica': 'mainstream',
|
||||
'NOS Nieuws': 'mainstream', 'NRC': 'mainstream', 'De Telegraaf': 'mainstream',
|
||||
'SVT Nyheter': 'mainstream', 'Dagens Nyheter': 'mainstream', 'Svenska Dagbladet': 'mainstream',
|
||||
|
||||
// Market/Finance
|
||||
'CNBC': 'market', 'MarketWatch': 'market', 'Yahoo Finance': 'market',
|
||||
@@ -322,6 +353,8 @@ export const SOURCE_PROPAGANDA_RISK: Record<string, SourceRiskProfile> = {
|
||||
'Al Arabiya': { risk: 'medium', stateAffiliated: 'Saudi Arabia', note: 'Saudi-owned, reflects Gulf perspective' },
|
||||
'TRT World': { risk: 'medium', stateAffiliated: 'Turkey', note: 'Turkish state broadcaster' },
|
||||
'France 24': { risk: 'medium', stateAffiliated: 'France', note: 'French state-funded, editorially independent' },
|
||||
'EuroNews': { risk: 'low', note: 'European public broadcaster consortium', knownBiases: ['Pro-EU'] },
|
||||
'Le Monde': { risk: 'low', note: 'French newspaper of record' },
|
||||
'DW News': { risk: 'medium', stateAffiliated: 'Germany', note: 'German state-funded, editorially independent' },
|
||||
'Voice of America': { risk: 'medium', stateAffiliated: 'USA', note: 'US government-funded' },
|
||||
'Kyiv Independent': { risk: 'medium', knownBiases: ['Pro-Ukraine'], note: 'Ukrainian perspective on Russia-Ukraine war' },
|
||||
@@ -353,13 +386,62 @@ const FULL_FEEDS: Record<string, Feed[]> = {
|
||||
{ name: 'NPR News', url: rss('https://feeds.npr.org/1001/rss.xml') },
|
||||
{ name: 'Guardian World', url: rss('https://www.theguardian.com/world/rss') },
|
||||
{ name: 'AP News', url: rss('https://news.google.com/rss/search?q=site:apnews.com&hl=en-US&gl=US&ceid=US:en') },
|
||||
{
|
||||
name: 'France 24',
|
||||
url: {
|
||||
en: rss('https://www.france24.com/en/rss'),
|
||||
fr: rss('https://www.france24.com/fr/rss'),
|
||||
es: rss('https://www.france24.com/es/rss'),
|
||||
ar: rss('https://www.france24.com/ar/rss')
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'EuroNews',
|
||||
url: {
|
||||
en: rss('https://www.euronews.com/rss?format=xml'),
|
||||
fr: rss('https://fr.euronews.com/rss?format=xml'),
|
||||
de: rss('https://de.euronews.com/rss?format=xml'),
|
||||
it: rss('https://it.euronews.com/rss?format=xml'),
|
||||
es: rss('https://es.euronews.com/rss?format=xml'),
|
||||
pt: rss('https://pt.euronews.com/rss?format=xml'),
|
||||
ru: rss('https://ru.euronews.com/rss?format=xml'),
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Le Monde',
|
||||
url: {
|
||||
en: rss('https://www.lemonde.fr/en/rss/full.xml'),
|
||||
fr: rss('https://www.lemonde.fr/rss/une.xml')
|
||||
}
|
||||
},
|
||||
{ name: 'Reuters World', url: rss('https://news.google.com/rss/search?q=site:reuters.com+world&hl=en-US&gl=US&ceid=US:en') },
|
||||
{ name: 'Politico', url: rss('https://news.google.com/rss/search?q=site:politico.com+when:1d&hl=en-US&gl=US&ceid=US:en') },
|
||||
{ name: 'The Diplomat', url: rss('https://thediplomat.com/feed/') },
|
||||
// Spanish (ES)
|
||||
{ name: 'El País', url: rss('https://feeds.elpais.com/mrss-s/pages/ep/site/elpais.com/portada'), lang: 'es' },
|
||||
{ name: 'El Mundo', url: rss('https://e00-elmundo.uecdn.es/elmundo/rss/portada.xml'), lang: 'es' },
|
||||
{ name: 'BBC Mundo', url: rss('https://www.bbc.com/mundo/index.xml'), lang: 'es' },
|
||||
// German (DE)
|
||||
{ name: 'Tagesschau', url: rss('https://www.tagesschau.de/xml/rss2/'), lang: 'de' },
|
||||
{ name: 'Der Spiegel', url: rss('https://www.spiegel.de/schlagzeilen/tops/index.rss'), lang: 'de' },
|
||||
{ name: 'Die Zeit', url: rss('https://newsfeed.zeit.de/index'), lang: 'de' },
|
||||
{ name: 'DW News', url: { en: rss('https://rss.dw.com/xml/rss-en-all'), de: rss('https://rss.dw.com/xml/rss-de-all'), es: rss('https://rss.dw.com/xml/rss-es-all') } },
|
||||
// Italian (IT)
|
||||
{ name: 'ANSA', url: rss('https://www.ansa.it/sito/notizie/topnews/topnews_rss.xml'), lang: 'it' },
|
||||
{ name: 'Corriere della Sera', url: rss('https://xml2.corriereobjects.it/rss/incipit.xml'), lang: 'it' },
|
||||
{ name: 'Repubblica', url: rss('https://www.repubblica.it/rss/homepage/rss2.0.xml'), lang: 'it' },
|
||||
// Dutch (NL)
|
||||
{ name: 'NOS Nieuws', url: rss('https://feeds.nos.nl/nosnieuwsalgemeen'), lang: 'nl' },
|
||||
{ name: 'NRC', url: rss('https://www.nrc.nl/rss/'), lang: 'nl' },
|
||||
{ name: 'De Telegraaf', url: rss('https://www.telegraaf.nl/rss'), lang: 'nl' },
|
||||
// Swedish (SV)
|
||||
{ name: 'SVT Nyheter', url: rss('https://www.svt.se/nyheter/rss.xml'), lang: 'sv' },
|
||||
{ name: 'Dagens Nyheter', url: rss('https://www.dn.se/rss/senaste-nytt/'), lang: 'sv' },
|
||||
{ name: 'Svenska Dagbladet', url: rss('https://www.svd.se/feed/articles.rss'), lang: 'sv' },
|
||||
],
|
||||
middleeast: [
|
||||
{ name: 'BBC Middle East', url: rss('https://feeds.bbci.co.uk/news/world/middle_east/rss.xml') },
|
||||
{ name: 'Al Jazeera', url: rss('https://www.aljazeera.com/xml/rss/all.xml') },
|
||||
{ name: 'Al Jazeera', url: { en: rss('https://www.aljazeera.com/xml/rss/all.xml'), ar: rss('https://www.aljazeera.net/aljazeerarss/a7c186be-1adb-4b11-a982-4783e765316e/4e17ecdc-8fb9-40de-a5d6-d00f72384a51') } },
|
||||
// AlArabiya blocks cloud IPs (Cloudflare), use Google News fallback
|
||||
{ name: 'Al Arabiya', url: rss('https://news.google.com/rss/search?q=site:english.alarabiya.net+when:2d&hl=en-US&gl=US&ceid=US:en') },
|
||||
// Arab News and Times of Israel removed — 403 from cloud IPs
|
||||
@@ -368,6 +450,9 @@ const FULL_FEEDS: Record<string, Feed[]> = {
|
||||
{ name: 'BBC Persian', url: rss('http://feeds.bbci.co.uk/persian/tv-and-radio-37434376/rss.xml') },
|
||||
{ name: 'Iran International', url: rss('https://news.google.com/rss/search?q=site:iranintl.com+when:2d&hl=en-US&gl=US&ceid=US:en') },
|
||||
{ name: 'Fars News', url: rss('https://news.google.com/rss/search?q=site:farsnews.ir+when:2d&hl=en-US&gl=US&ceid=US:en') },
|
||||
{ name: 'L\'Orient-Le Jour', url: rss('https://www.lorientlejour.com/rss'), lang: 'fr' },
|
||||
{ name: 'Haaretz', url: rss('https://www.haaretz.com/cmlink/1.4608793') },
|
||||
{ name: 'Arab News', url: rss('https://www.arabnews.com/cat/1/rss.xml') },
|
||||
],
|
||||
tech: [
|
||||
{ name: 'Hacker News', url: rss('https://hnrss.org/frontpage') },
|
||||
@@ -447,12 +532,21 @@ const FULL_FEEDS: Record<string, Feed[]> = {
|
||||
{ name: 'Sahel Crisis', url: rss('https://news.google.com/rss/search?q=(Sahel+OR+Mali+OR+Niger+OR+"Burkina+Faso"+OR+Wagner)+when:3d&hl=en-US&gl=US&ceid=US:en') },
|
||||
{ name: 'News24', url: railwayRss('https://feeds.capi24.com/v1/Search/articles/news24/Africa/rss') },
|
||||
{ name: 'BBC Africa', url: rss('https://feeds.bbci.co.uk/news/world/africa/rss.xml') },
|
||||
{ name: 'Jeune Afrique', url: rss('https://www.jeuneafrique.com/feed/'), lang: 'fr' },
|
||||
{ name: 'Africanews', url: { en: rss('https://www.africanews.com/feed/rss'), fr: rss('https://fr.africanews.com/feed/rss') } },
|
||||
{ name: 'BBC Afrique', url: rss('https://www.bbc.com/afrique/index.xml'), lang: 'fr' },
|
||||
],
|
||||
latam: [
|
||||
{ name: 'Latin America', url: rss('https://news.google.com/rss/search?q=(Brazil+OR+Mexico+OR+Argentina+OR+Venezuela+OR+Colombia)+when:2d&hl=en-US&gl=US&ceid=US:en') },
|
||||
{ name: 'BBC Latin America', url: rss('https://feeds.bbci.co.uk/news/world/latin_america/rss.xml') },
|
||||
{ name: 'Reuters LatAm', url: rss('https://news.google.com/rss/search?q=site:reuters.com+(Brazil+OR+Mexico+OR+Argentina)+when:3d&hl=en-US&gl=US&ceid=US:en') },
|
||||
{ name: 'Guardian Americas', url: rss('https://www.theguardian.com/world/americas/rss') },
|
||||
// Localized Feeds
|
||||
{ name: 'Clarín', url: rss('https://www.clarin.com/rss/lo-ultimo/'), lang: 'es' },
|
||||
{ name: 'O Globo', url: rss('https://oglobo.globo.com/rss/top_noticias/'), lang: 'pt' },
|
||||
{ name: 'Folha de S.Paulo', url: rss('https://feeds.folha.uol.com.br/emcimadahora/rss091.xml'), lang: 'pt' },
|
||||
{ name: 'El Tiempo', url: rss('https://www.eltiempo.com/rss/mundo_latinoamerica.xml'), lang: 'es' },
|
||||
{ name: 'El Universal', url: rss('https://www.eluniversal.com.mx/rss.xml'), lang: 'es' },
|
||||
],
|
||||
asia: [
|
||||
{ name: 'Asia News', url: rss('https://news.google.com/rss/search?q=(China+OR+Japan+OR+Korea+OR+India+OR+ASEAN)+when:2d&hl=en-US&gl=US&ceid=US:en') },
|
||||
@@ -461,8 +555,11 @@ const FULL_FEEDS: Record<string, Feed[]> = {
|
||||
{ name: 'Reuters Asia', url: rss('https://news.google.com/rss/search?q=site:reuters.com+(China+OR+Japan+OR+Taiwan+OR+Korea)+when:3d&hl=en-US&gl=US&ceid=US:en') },
|
||||
{ name: 'NHK World', url: rss('https://rsshub.app/nhk/news/en') },
|
||||
{ name: 'Nikkei Asia', url: railwayRss('https://asia.nikkei.com/rss/feed/nar') },
|
||||
{ name: 'MIIT (China)', url: rss('https://rsshub.app/gov/miit/zcjd') },
|
||||
{ name: 'MOFCOM (China)', url: rss('https://rsshub.app/gov/mofcom/article/xwfb') },
|
||||
{ name: 'Asahi Shimbun', url: rss('https://www.asahi.com/rss/asahi/newsheadlines.rdf'), lang: 'ja' },
|
||||
{ name: 'The Hindu', url: rss('https://www.thehindu.com/news/national/feeder/default.rss'), lang: 'en' },
|
||||
{ name: 'CNA', url: rss('https://www.channelnewsasia.com/api/v1/rss-outbound-feed?_format=xml') },
|
||||
{ name: 'MIIT (China)', url: rss('https://rsshub.app/gov/miit/zcjd'), lang: 'zh' },
|
||||
{ name: 'MOFCOM (China)', url: rss('https://rsshub.app/gov/mofcom/article/xwfb'), lang: 'zh' },
|
||||
],
|
||||
energy: [
|
||||
{ name: 'Oil & Gas', url: rss('https://news.google.com/rss/search?q=(oil+price+OR+OPEC+OR+"natural+gas"+OR+pipeline+OR+LNG)+when:2d&hl=en-US&gl=US&ceid=US:en') },
|
||||
|
||||
2974
src/locales/fr.json
2974
src/locales/fr.json
File diff suppressed because it is too large
Load Diff
@@ -70,6 +70,7 @@ export interface NewsItemCore {
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
locationName?: string;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
export type NewsItemWithTier = NewsItemCore & { tier: number };
|
||||
@@ -90,6 +91,7 @@ export interface ClusteredEventCore {
|
||||
threat?: import('./threat-classifier').ThreatClassification;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
export interface PredictionMarketCore {
|
||||
@@ -292,6 +294,7 @@ export function clusterNewsCore(
|
||||
monitorColor: cluster.find(i => i.monitorColor)?.monitorColor,
|
||||
threat,
|
||||
...(clusterLat != null && { lat: clusterLat, lon: clusterLon }),
|
||||
lang: primary.lang,
|
||||
};
|
||||
}).sort((a, b) => b.lastUpdated.getTime() - a.lastUpdated.getTime());
|
||||
}
|
||||
|
||||
@@ -29,5 +29,5 @@ export * from './cross-module-integration';
|
||||
export * from './data-freshness';
|
||||
export * from './usa-spending';
|
||||
export * from './oil-analytics';
|
||||
export { generateSummary } from './summarization';
|
||||
export { generateSummary, translateText } from './summarization';
|
||||
export * from './cached-theater-posture';
|
||||
|
||||
@@ -5,6 +5,7 @@ import { classifyByKeyword, classifyWithAI } from './threat-classifier';
|
||||
import { inferGeoHubsFromTitle } from './geo-hub-index';
|
||||
import { getPersistentCache, setPersistentCache } from './persistent-cache';
|
||||
import { ingestHeadlines } from './trending-keywords';
|
||||
import { getCurrentLanguage } from './i18n';
|
||||
|
||||
// Per-feed circuit breaker: track failures and cooldowns
|
||||
const FEED_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes after failure
|
||||
@@ -84,6 +85,10 @@ function recordFeedSuccess(feedName: string): void {
|
||||
feedFailures.delete(feedName);
|
||||
}
|
||||
|
||||
export function getFeedFailures(): Map<string, { count: number; cooldownUntil: number }> {
|
||||
return new Map(feedFailures);
|
||||
}
|
||||
|
||||
function toAiKey(title: string): string {
|
||||
return title.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
@@ -128,7 +133,15 @@ export async function fetchFeed(feed: Feed): Promise<NewsItem[]> {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchWithProxy(feed.url);
|
||||
let url = typeof feed.url === 'string' ? feed.url : feed.url['en'];
|
||||
if (typeof feed.url !== 'string') {
|
||||
const lang = getCurrentLanguage();
|
||||
url = feed.url[lang] || feed.url['en'] || Object.values(feed.url)[0] || '';
|
||||
}
|
||||
|
||||
if (!url) throw new Error(`No URL found for feed ${feed.name}`);
|
||||
|
||||
const response = await fetchWithProxy(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const text = await response.text();
|
||||
const parser = new DOMParser();
|
||||
@@ -176,6 +189,7 @@ export async function fetchFeed(feed: Feed): Promise<NewsItem[]> {
|
||||
isAlert,
|
||||
threat,
|
||||
...(topGeo && { lat: topGeo.hub.lat, lon: topGeo.hub.lon, locationName: topGeo.hub.name }),
|
||||
lang: feed.lang,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -201,7 +215,7 @@ export async function fetchFeed(feed: Feed): Promise<NewsItem[]> {
|
||||
item.threat = aiResult;
|
||||
item.isAlert = aiResult.level === 'critical' || aiResult.level === 'high';
|
||||
}
|
||||
}).catch(() => {});
|
||||
}).catch(() => { });
|
||||
}
|
||||
|
||||
return parsed;
|
||||
@@ -222,7 +236,14 @@ export async function fetchCategoryFeeds(
|
||||
): Promise<NewsItem[]> {
|
||||
const topLimit = 20;
|
||||
const batchSize = options.batchSize ?? 5;
|
||||
const batches = chunkArray(feeds, batchSize);
|
||||
const currentLang = getCurrentLanguage();
|
||||
|
||||
// Filter feeds by language:
|
||||
// 1. Feeds with no explicit 'lang' are universal (or multi-url handled inside fetchFeed)
|
||||
// 2. Feeds with explicit 'lang' must match current UI language
|
||||
const filteredFeeds = feeds.filter(feed => !feed.lang || feed.lang === currentLang);
|
||||
|
||||
const batches = chunkArray(filteredFeeds, batchSize);
|
||||
const topItems: NewsItem[] = [];
|
||||
let totalItems = 0;
|
||||
|
||||
|
||||
@@ -18,13 +18,13 @@ export interface SummarizationResult {
|
||||
|
||||
export type ProgressCallback = (step: number, total: number, message: string) => void;
|
||||
|
||||
async function tryGroq(headlines: string[], geoContext?: string): Promise<SummarizationResult | null> {
|
||||
async function tryGroq(headlines: string[], geoContext?: string, lang?: string): Promise<SummarizationResult | null> {
|
||||
if (!isFeatureAvailable('aiGroq')) return null;
|
||||
try {
|
||||
const response = await fetch('/api/groq-summarize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ headlines, mode: 'brief', geoContext, variant: SITE_VARIANT }),
|
||||
body: JSON.stringify({ headlines, mode: 'brief', geoContext, variant: SITE_VARIANT, lang }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -47,13 +47,13 @@ async function tryGroq(headlines: string[], geoContext?: string): Promise<Summar
|
||||
}
|
||||
}
|
||||
|
||||
async function tryOpenRouter(headlines: string[], geoContext?: string): Promise<SummarizationResult | null> {
|
||||
async function tryOpenRouter(headlines: string[], geoContext?: string, lang?: string): Promise<SummarizationResult | null> {
|
||||
if (!isFeatureAvailable('aiOpenRouter')) return null;
|
||||
try {
|
||||
const response = await fetch('/api/openrouter-summarize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ headlines, mode: 'brief', geoContext, variant: SITE_VARIANT }),
|
||||
body: JSON.stringify({ headlines, mode: 'brief', geoContext, variant: SITE_VARIANT, lang }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -112,7 +112,8 @@ async function tryBrowserT5(headlines: string[]): Promise<SummarizationResult |
|
||||
export async function generateSummary(
|
||||
headlines: string[],
|
||||
onProgress?: ProgressCallback,
|
||||
geoContext?: string
|
||||
geoContext?: string,
|
||||
lang: string = 'en'
|
||||
): Promise<SummarizationResult | null> {
|
||||
if (!headlines || headlines.length < 2) {
|
||||
return null;
|
||||
@@ -122,14 +123,14 @@ export async function generateSummary(
|
||||
|
||||
// Step 1: Try Groq (fast, 14.4K/day with 8b-instant + Redis cache)
|
||||
onProgress?.(1, totalSteps, 'Connecting to Groq AI...');
|
||||
const groqResult = await tryGroq(headlines, geoContext);
|
||||
const groqResult = await tryGroq(headlines, geoContext, lang);
|
||||
if (groqResult) {
|
||||
return groqResult;
|
||||
}
|
||||
|
||||
// Step 2: Try OpenRouter (fallback, 50/day + Redis cache)
|
||||
onProgress?.(2, totalSteps, 'Trying OpenRouter...');
|
||||
const openRouterResult = await tryOpenRouter(headlines, geoContext);
|
||||
const openRouterResult = await tryOpenRouter(headlines, geoContext, lang);
|
||||
if (openRouterResult) {
|
||||
return openRouterResult;
|
||||
}
|
||||
@@ -145,3 +146,64 @@ export async function generateSummary(
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Translate text using the fallback chain
|
||||
* @param text Text to translate
|
||||
* @param targetLang Target language code (e.g., 'fr', 'es')
|
||||
*/
|
||||
export async function translateText(
|
||||
text: string,
|
||||
targetLang: string,
|
||||
onProgress?: ProgressCallback
|
||||
): Promise<string | null> {
|
||||
if (!text) return null;
|
||||
|
||||
// Step 1: Try Groq
|
||||
if (isFeatureAvailable('aiGroq')) {
|
||||
onProgress?.(1, 2, 'Translating with Groq...');
|
||||
try {
|
||||
const response = await fetch('/api/groq-summarize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
headlines: [text],
|
||||
mode: 'translate',
|
||||
variant: targetLang
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return data.summary;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Groq translation failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Try OpenRouter
|
||||
if (isFeatureAvailable('aiOpenRouter')) {
|
||||
onProgress?.(2, 2, 'Translating with OpenRouter...');
|
||||
try {
|
||||
const response = await fetch('/api/openrouter-summarize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
headlines: [text],
|
||||
mode: 'translate',
|
||||
variant: targetLang
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return data.summary;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('OpenRouter translation failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* Language Switcher */
|
||||
.lang-select {
|
||||
/* Legacy selector styles - keeping for fallback if needed, or remove if fully replaced */
|
||||
padding: 4px 24px 4px 6px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
@@ -15,11 +16,89 @@
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.lang-select:hover {
|
||||
border-color: var(--text-dim);
|
||||
/* Custom Language Selector */
|
||||
.custom-lang-selector {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.lang-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
.lang-selector-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
min-width: 70px;
|
||||
height: 26px; /* Match other header buttons */
|
||||
}
|
||||
|
||||
.lang-selector-btn:hover {
|
||||
border-color: var(--text-dim);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.lang-flag-icon {
|
||||
width: 16px;
|
||||
height: 12px;
|
||||
object-fit: cover;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.lang-code {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.lang-arrow {
|
||||
font-size: 8px;
|
||||
color: var(--text-dim);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.lang-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.lang-dropdown.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lang-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.lang-option:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.lang-option.active {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.lang-option .lang-name {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
2028
src/styles/main.css
2028
src/styles/main.css
File diff suppressed because it is too large
Load Diff
@@ -9,12 +9,35 @@
|
||||
}
|
||||
|
||||
/* Header element margins */
|
||||
[dir="rtl"] .variant-switcher { margin-right: 0; margin-left: 6px; }
|
||||
[dir="rtl"] .version { margin-left: 0; margin-right: 6px; }
|
||||
[dir="rtl"] .update-badge { margin-left: 0; margin-right: 8px; }
|
||||
[dir="rtl"] .update-badge-dismiss { margin-left: 0; margin-right: 2px; }
|
||||
[dir="rtl"] .github-link { margin-left: 0; margin-right: 8px; }
|
||||
[dir="rtl"] .search-btn { margin-right: 0; margin-left: 8px; }
|
||||
[dir="rtl"] .variant-switcher {
|
||||
margin-right: 0;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
[dir="rtl"] .version {
|
||||
margin-left: 0;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
[dir="rtl"] .update-badge {
|
||||
margin-left: 0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
[dir="rtl"] .update-badge-dismiss {
|
||||
margin-left: 0;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
[dir="rtl"] .github-link {
|
||||
margin-left: 0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
[dir="rtl"] .search-btn {
|
||||
margin-right: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Border-left accent bars → border-right */
|
||||
[dir="rtl"] .panel-summary {
|
||||
@@ -26,14 +49,38 @@
|
||||
border-left: none;
|
||||
border-right: 3px solid var(--accent);
|
||||
}
|
||||
[dir="rtl"] .signal-item.velocity_spike { border-right-color: var(--red); }
|
||||
[dir="rtl"] .signal-item.keyword_spike { border-right-color: var(--semantic-high); }
|
||||
[dir="rtl"] .signal-item.prediction_leads_news { border-right-color: var(--yellow); }
|
||||
[dir="rtl"] .signal-item.silent_divergence { border-right-color: var(--green); }
|
||||
[dir="rtl"] .signal-item.convergence { border-right-color: var(--defcon-4); }
|
||||
[dir="rtl"] .signal-item.triangulation { border-right-color: var(--semantic-high); }
|
||||
[dir="rtl"] .signal-item.flow_drop { border-right-color: var(--semantic-info); }
|
||||
[dir="rtl"] .signal-item.flow_price_divergence { border-right-color: var(--semantic-normal); }
|
||||
|
||||
[dir="rtl"] .signal-item.velocity_spike {
|
||||
border-right-color: var(--red);
|
||||
}
|
||||
|
||||
[dir="rtl"] .signal-item.keyword_spike {
|
||||
border-right-color: var(--semantic-high);
|
||||
}
|
||||
|
||||
[dir="rtl"] .signal-item.prediction_leads_news {
|
||||
border-right-color: var(--yellow);
|
||||
}
|
||||
|
||||
[dir="rtl"] .signal-item.silent_divergence {
|
||||
border-right-color: var(--green);
|
||||
}
|
||||
|
||||
[dir="rtl"] .signal-item.convergence {
|
||||
border-right-color: var(--defcon-4);
|
||||
}
|
||||
|
||||
[dir="rtl"] .signal-item.triangulation {
|
||||
border-right-color: var(--semantic-high);
|
||||
}
|
||||
|
||||
[dir="rtl"] .signal-item.flow_drop {
|
||||
border-right-color: var(--semantic-info);
|
||||
}
|
||||
|
||||
[dir="rtl"] .signal-item.flow_price_divergence {
|
||||
border-right-color: var(--semantic-normal);
|
||||
}
|
||||
|
||||
/* News/intel card borders */
|
||||
[dir="rtl"] .news-card,
|
||||
@@ -45,6 +92,15 @@
|
||||
border-right-style: solid;
|
||||
}
|
||||
|
||||
[dir="rtl"] .item.alert {
|
||||
border-left: none;
|
||||
border-right: 2px solid var(--red);
|
||||
padding-left: 0;
|
||||
padding-right: 8px;
|
||||
margin-left: 0;
|
||||
margin-right: -8px;
|
||||
}
|
||||
|
||||
/* Padding-left lists → padding-right */
|
||||
[dir="rtl"] .panel-info-tooltip ul {
|
||||
padding-left: 0;
|
||||
@@ -52,8 +108,13 @@
|
||||
}
|
||||
|
||||
/* text-align flips */
|
||||
[dir="rtl"] .related-asset { text-align: right; }
|
||||
[dir="rtl"] .export-option { text-align: right; }
|
||||
[dir="rtl"] .related-asset {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
[dir="rtl"] .export-option {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* margin-left: auto push patterns → margin-right: auto */
|
||||
[dir="rtl"] .cii-share-btn,
|
||||
@@ -62,7 +123,8 @@
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
[dir="rtl"] .sources-counter {
|
||||
margin-right: auto;
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,12 @@ export type PropagandaRisk = 'low' | 'medium' | 'high';
|
||||
|
||||
export interface Feed {
|
||||
name: string;
|
||||
url: string;
|
||||
url: string | Record<string, string>;
|
||||
type?: string;
|
||||
region?: string;
|
||||
propagandaRisk?: PropagandaRisk;
|
||||
stateAffiliated?: string; // e.g., "Russia", "China", "Iran"
|
||||
lang?: string; // ISO 2-letter code for filtering
|
||||
}
|
||||
|
||||
export type { ThreatClassification, ThreatLevel, EventCategory } from '@/services/threat-classifier';
|
||||
@@ -23,6 +24,7 @@ export interface NewsItem {
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
locationName?: string;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
export type VelocityLevel = 'normal' | 'elevated' | 'spike';
|
||||
@@ -53,6 +55,7 @@ export interface ClusteredEvent {
|
||||
threat?: import('@/services/threat-classifier').ThreatClassification;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
export type AssetType = 'pipeline' | 'cable' | 'datacenter' | 'base' | 'nuclear';
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
export function formatTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const diff = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
const lang = getCurrentLanguage();
|
||||
|
||||
if (diff < 60) return 'Just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
// Safe fallback if Intl is not available (though it is in all modern browsers)
|
||||
try {
|
||||
const rtf = new Intl.RelativeTimeFormat(lang, { numeric: 'auto' });
|
||||
|
||||
if (diff < 60) return rtf.format(-Math.round(diff), 'second');
|
||||
if (diff < 3600) return rtf.format(-Math.round(diff / 60), 'minute');
|
||||
if (diff < 86400) return rtf.format(-Math.round(diff / 3600), 'hour');
|
||||
return rtf.format(-Math.round(diff / 86400), 'day');
|
||||
} catch (e) {
|
||||
if (diff < 60) return 'Just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatPrice(price: number): string {
|
||||
@@ -138,3 +149,5 @@ export * from './analysis-constants';
|
||||
export { getCSSColor, invalidateColorCache } from './theme-colors';
|
||||
export { getStoredTheme, getCurrentTheme, setTheme, applyStoredTheme } from './theme-manager';
|
||||
export type { Theme } from './theme-manager';
|
||||
|
||||
import { getCurrentLanguage } from '../services/i18n';
|
||||
|
||||
Reference in New Issue
Block a user