i18n: Complete DE/EN language switcher integration

- Add LangManager with 270+ translation keys, anti-flicker lang detection
- Replace all hardcoded German strings in app.js, components.js, dashboard.html, index.html
- Dynamic getter properties for fact-check labels, category badges
- Language-aware map tiles (DE/EN OSM servers), CSP updated for tile.openstreetmap.org
- Lang switcher in header bar and login page
- Locale-aware date formatting, translateApiError for backend messages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-03-05 16:13:11 +01:00
Ursprung 1644f8786c
Commit 44997d511b
7 geänderte Dateien mit 422 neuen und 362 gelöschten Zeilen

Datei anzeigen

@@ -29,9 +29,9 @@ const UI = {
<span class="incident-dot ${dotClass}" id="dot-${incident.id}" aria-hidden="true"></span>
<div style="flex:1;min-width:0;">
<div class="incident-name">${this.escape(incident.title)}</div>
<div class="incident-meta">${incident.article_count} Artikel &middot; ${this.escape(creator)}</div>
<div class="incident-meta">${LangManager.t('sidebar.articles', { n: incident.article_count })} &middot; ${this.escape(creator)}</div>
</div>
${incident.visibility === 'private' ? '<span class="badge badge-private" style="font-size:9px;" aria-label="Private Lage">PRIVAT</span>' : ''}
${incident.visibility === 'private' ? `<span class="badge badge-private" style="font-size:9px;" aria-label="Private Lage">${LangManager.t('status.private')}</span>` : ''}
${incident.refresh_mode === 'auto' ? '<span class="badge badge-auto" role="img" aria-label="Auto-Refresh aktiv">&#x21bb;</span>' : ''}
</div>
`;
@@ -40,34 +40,15 @@ const UI = {
/**
* Faktencheck-Eintrag rendern.
*/
factCheckLabels: {
confirmed: 'Bestätigt durch mehrere Quellen',
unconfirmed: 'Nicht unabhängig bestätigt',
contradicted: 'Widerlegt',
developing: 'Faktenlage noch im Fluss',
established: 'Gesicherter Fakt (3+ Quellen)',
disputed: 'Umstrittener Sachverhalt',
unverified: 'Nicht unabhängig verifizierbar',
_fcStatuses: ['confirmed','unconfirmed','contradicted','developing','established','disputed','unverified'],
get factCheckLabels() {
const o = {}; this._fcStatuses.forEach(s => o[s] = LangManager.t('fc.' + s)); return o;
},
factCheckTooltips: {
confirmed: 'Bestätigt: Mindestens zwei unabhängige, seriöse Quellen stützen diese Aussage übereinstimmend.',
established: 'Gesichert: Drei oder mehr unabhängige Quellen bestätigen den Sachverhalt. Hohe Verlässlichkeit.',
developing: 'Unklar: Die Faktenlage ist noch im Fluss. Neue Informationen können das Bild verändern.',
unconfirmed: 'Unbestätigt: Bisher nur aus einer Quelle bekannt. Eine unabhängige Bestätigung steht aus.',
unverified: 'Ungeprüft: Die Aussage konnte bisher nicht anhand verfügbarer Quellen überprüft werden.',
disputed: 'Umstritten: Quellen widersprechen sich. Es gibt sowohl stützende als auch widersprechende Belege.',
contradicted: 'Widerlegt: Zuverlässige Quellen widersprechen dieser Aussage. Wahrscheinlich falsch.',
get factCheckTooltips() {
const o = {}; this._fcStatuses.forEach(s => o[s] = LangManager.t('fc_tooltip.' + s)); return o;
},
factCheckChipLabels: {
confirmed: 'Bestätigt',
unconfirmed: 'Unbestätigt',
contradicted: 'Widerlegt',
developing: 'Unklar',
established: 'Gesichert',
disputed: 'Umstritten',
unverified: 'Ungeprüft',
get factCheckChipLabels() {
const o = {}; this._fcStatuses.forEach(s => o[s] = LangManager.t('fc_chip.' + s)); return o;
},
factCheckIcons: {
@@ -106,7 +87,7 @@ const UI = {
</label>`;
}).join('');
return `<button class="fc-dropdown-toggle" onclick="App.toggleFcDropdown(event)" aria-haspopup="true" aria-expanded="false">Filter</button>
return `<button class="fc-dropdown-toggle" onclick="App.toggleFcDropdown(event)" aria-haspopup="true" aria-expanded="false">${LangManager.t('evidence.filter')}</button>
<div class="fc-dropdown-menu" id="fc-dropdown-menu">${items}</div>`;
},
@@ -120,7 +101,7 @@ const UI = {
<div style="flex:1;">
<div class="factcheck-claim">${this.escape(fc.claim)}</div>
<div style="display:flex;align-items:center;gap:6px;margin-top:2px;">
<span class="factcheck-sources">${count} Quelle${count !== 1 ? 'n' : ''}</span>
<span class="factcheck-sources">${count !== 1 ? LangManager.t('evidence.sources', { n: count }) : LangManager.t('evidence.source', { n: count })}</span>
</div>
<div class="evidence-block">${this.renderEvidence(fc.evidence || '')}</div>
</div>
@@ -132,7 +113,7 @@ const UI = {
* Evidence mit erklärenden Text UND Quellen-Chips rendern.
*/
renderEvidence(text) {
if (!text) return '<span class="evidence-empty">Keine Belege</span>';
if (!text) return `<span class="evidence-empty">${LangManager.t('evidence.empty')}</span>`;
const urls = text.match(/https?:\/\/[^\s,)]+/g) || [];
if (urls.length === 0) {
@@ -223,12 +204,12 @@ const UI = {
bar.classList.remove('progress-bar--complete', 'progress-bar--error');
const steps = {
queued: { active: 0, label: 'In Warteschlange...' },
researching: { active: 1, label: 'Recherchiert Quellen...' },
deep_researching: { active: 1, label: 'Tiefenrecherche läuft...' },
analyzing: { active: 2, label: 'Analysiert Meldungen...' },
factchecking: { active: 3, label: 'Faktencheck läuft...' },
cancelling: { active: 0, label: 'Wird abgebrochen...' },
queued: { active: 0, label: LangManager.t('progress.queued') },
researching: { active: 1, label: LangManager.t('progress.researching') },
deep_researching: { active: 1, label: LangManager.t('progress.deep_researching') },
analyzing: { active: 2, label: LangManager.t('progress.analyzing') },
factchecking: { active: 3, label: LangManager.t('progress.factchecking') },
cancelling: { active: 0, label: LangManager.t('progress.cancelling') },
};
const step = steps[status] || steps.queued;
@@ -236,7 +217,7 @@ const UI = {
// Queue-Position anzeigen
let labelText = step.label;
if (status === 'queued' && extra.queue_position > 1) {
labelText = `In Warteschlange (Position ${extra.queue_position})...`;
labelText = LangManager.t('progress.queued_position', { position: extra.queue_position });
} else if (extra.detail) {
labelText = extra.detail;
}
@@ -325,24 +306,24 @@ const UI = {
// Label mit Summary
const parts = [];
if (data.new_articles > 0) {
parts.push(`${data.new_articles} neue Artikel`);
parts.push(LangManager.t('progress.new_articles', { n: data.new_articles }));
}
if (data.confirmed_count > 0) {
parts.push(`${data.confirmed_count} Fakten bestätigt`);
parts.push(LangManager.t('progress.facts_confirmed', { n: data.confirmed_count }));
}
if (data.contradicted_count > 0) {
parts.push(`${data.contradicted_count} widerlegt`);
parts.push(LangManager.t('progress.contradicted', { n: data.contradicted_count }));
}
const summaryText = parts.length > 0 ? parts.join(', ') : 'Keine neuen Entwicklungen';
const summaryText = parts.length > 0 ? parts.join(', ') : LangManager.t('progress.no_developments');
const label = document.getElementById('progress-label');
if (label) label.textContent = `Abgeschlossen: ${summaryText}`;
if (label) label.textContent = LangManager.t('progress.complete_detail', { summary: summaryText });
// Cancel-Button ausblenden
const cancelBtn = document.getElementById('progress-cancel-btn');
if (cancelBtn) cancelBtn.style.display = 'none';
bar.setAttribute('aria-valuenow', '100');
bar.setAttribute('aria-valuetext', 'Abgeschlossen');
bar.setAttribute('aria-valuetext', LangManager.t('progress.complete'));
},
/**
@@ -363,8 +344,8 @@ const UI = {
const label = document.getElementById('progress-label');
if (label) {
label.textContent = willRetry
? `Fehlgeschlagen \u2014 erneuter Versuch in ${delay}s...`
: `Fehlgeschlagen: ${errorMsg}`;
? LangManager.t('progress.failed_retry', { delay })
: LangManager.t('progress.failed', { error: errorMsg });
}
// Cancel-Button ausblenden
@@ -406,7 +387,7 @@ const UI = {
* Zusammenfassung mit Inline-Zitaten und Quellenverzeichnis rendern.
*/
renderSummary(summary, sourcesJson, incidentType) {
if (!summary) return '<span style="color:var(--text-tertiary);">Noch keine Zusammenfassung.</span>';
if (!summary) return `<span style="color:var(--text-tertiary);">${LangManager.t('empty.no_summary')}</span>`;
let sources = [];
try { sources = JSON.parse(sourcesJson || '[]'); } catch(e) {}
@@ -451,7 +432,7 @@ const UI = {
// Nach Quelle aggregieren
const sourceMap = {};
articles.forEach(a => {
const name = a.source || 'Unbekannt';
const name = a.source || LangManager.t('time.unknown');
if (!sourceMap[name]) {
sourceMap[name] = { count: 0, languages: new Set(), urls: [] };
}
@@ -476,7 +457,7 @@ const UI = {
.join('');
let html = `<div class="source-overview-header">`;
html += `<span class="source-overview-stat">${articles.length} Artikel aus ${sources.length} Quellen</span>`;
html += `<span class="source-overview-stat">${LangManager.t('sources.articles_from_sources', { articles: articles.length, sources: sources.length })}</span>`;
html += `<div class="source-lang-chips">${langChips}</div>`;
html += `</div>`;
@@ -497,17 +478,15 @@ const UI = {
/**
* Kategorie-Labels.
*/
_categoryLabels: {
'nachrichtenagentur': 'Agentur',
'oeffentlich-rechtlich': 'ÖR',
'qualitaetszeitung': 'Qualität',
'behoerde': 'Behörde',
'fachmedien': 'Fach',
'think-tank': 'Think Tank',
'international': 'Intl.',
'regional': 'Regional',
'boulevard': 'Boulevard',
'sonstige': 'Sonstige',
_catKeys: ['nachrichtenagentur','oeffentlich_rechtlich','qualitaetszeitung','behoerde','fachmedien','think_tank','international','regional','boulevard','sonstige'],
_catDomainMap: {'oeffentlich-rechtlich':'oeffentlich_rechtlich','think-tank':'think_tank'},
get _categoryLabels() {
const o = {};
this._catKeys.forEach(k => {
const domainKey = k.replace(/_/g, '-');
o[domainKey] = LangManager.t('sources.cat_short_' + k);
});
return o;
},
/**
@@ -517,7 +496,7 @@ const UI = {
const catLabel = this._categoryLabels[feeds[0]?.category] || feeds[0]?.category || '';
const feedCount = feeds.filter(f => f.source_type !== 'excluded').length;
const hasMultiple = feedCount > 1;
const displayName = domain || feeds[0]?.name || 'Unbekannt';
const displayName = domain || feeds[0]?.name || LangManager.t('time.unknown');
const escapedDomain = this.escape(domain);
if (isExcluded) {
@@ -528,10 +507,10 @@ const UI = {
<div class="source-group-info">
<span class="source-group-name">${this.escape(displayName)}</span>${notesHtml}
</div>
<span class="source-excluded-badge">Gesperrt</span>
<span class="source-excluded-badge">${LangManager.t('sources.excluded')}</span>
<div class="source-group-actions">
<button class="btn btn-small btn-secondary" onclick="App.unblockDomain('${escapedDomain}')">Entsperren</button>
<button class="source-delete-btn" onclick="App.deleteDomain('${escapedDomain}')" title="Löschen" aria-label="Löschen">&times;</button>
<button class="btn btn-small btn-secondary" onclick="App.unblockDomain('${escapedDomain}')">${LangManager.t('btn.unblock')}</button>
<button class="source-delete-btn" onclick="App.deleteDomain('${escapedDomain}')" title="${LangManager.t('btn.delete')}" aria-label="${LangManager.t('btn.delete')}">&times;</button>
</div>
</div>
</div>`;
@@ -548,22 +527,22 @@ const UI = {
realFeeds.forEach((feed, i) => {
const isLast = i === realFeeds.length - 1;
const connector = isLast ? '\u2514\u2500' : '\u251C\u2500';
const typeLabel = feed.source_type === 'rss_feed' ? 'RSS' : 'Web';
const typeLabel = feed.source_type === 'rss_feed' ? 'RSS' : 'Web'; // RSS/Web are brand names, no translation needed
const urlDisplay = feed.url ? this._shortenUrl(feed.url) : '';
feedRows += `<div class="source-feed-row">
<span class="source-feed-connector">${connector}</span>
<span class="source-feed-name">${this.escape(feed.name)}</span>
<span class="source-type-badge type-${feed.source_type}">${typeLabel}</span>
<span class="source-feed-url" title="${this.escape(feed.url || '')}">${this.escape(urlDisplay)}</span>
<button class="source-edit-btn" onclick="App.editSource(${feed.id})" title="Bearbeiten" aria-label="Bearbeiten">&#9998;</button>
<button class="source-delete-btn" onclick="App.deleteSingleFeed(${feed.id})" title="Löschen" aria-label="Löschen">&times;</button>
<button class="source-edit-btn" onclick="App.editSource(${feed.id})" title="${LangManager.t('btn.edit')}" aria-label="${LangManager.t('btn.edit')}">&#9998;</button>
<button class="source-delete-btn" onclick="App.deleteSingleFeed(${feed.id})" title="${LangManager.t('btn.delete')}" aria-label="${LangManager.t('btn.delete')}">&times;</button>
</div>`;
});
feedRows += '</div>';
}
const feedCountBadge = feedCount > 0
? `<span class="source-feed-count">${feedCount} Feed${feedCount !== 1 ? 's' : ''}</span>`
? `<span class="source-feed-count">${feedCount !== 1 ? LangManager.t('sources.feeds_count', {n: feedCount}) : LangManager.t('sources.feed_count', {n: feedCount})}</span>`
: '';
return `<div class="source-group">
@@ -575,9 +554,9 @@ const UI = {
<span class="source-category-badge cat-${feeds[0]?.category || 'sonstige'}">${catLabel}</span>
${feedCountBadge}
<div class="source-group-actions" onclick="event.stopPropagation()">
${!hasMultiple && feeds[0]?.id ? `<button class="source-edit-btn" onclick="App.editSource(${feeds[0].id})" title="Bearbeiten" aria-label="Bearbeiten">&#9998;</button>` : ''}
<button class="btn btn-small btn-secondary" onclick="App.blockDomainDirect('${escapedDomain}')">Sperren</button>
<button class="source-delete-btn" onclick="App.deleteDomain('${escapedDomain}')" title="Löschen" aria-label="Löschen">&times;</button>
${!hasMultiple && feeds[0]?.id ? `<button class="source-edit-btn" onclick="App.editSource(${feeds[0].id})" title="${LangManager.t('btn.edit')}" aria-label="${LangManager.t('btn.edit')}">&#9998;</button>` : ''}
<button class="btn btn-small btn-secondary" onclick="App.blockDomainDirect('${escapedDomain}')">${LangManager.t('btn.block')}</button>
<button class="source-delete-btn" onclick="App.deleteDomain('${escapedDomain}')" title="${LangManager.t('btn.delete')}" aria-label="${LangManager.t('btn.delete')}">&times;</button>
</div>
</div>
${feedRows}
@@ -617,7 +596,7 @@ const UI = {
// Statistik trotzdem anzeigen
if (locations && locations.length > 0) {
const totalArticles = locations.reduce((s, l) => s + l.article_count, 0);
if (statsEl) statsEl.textContent = `${locations.length} Orte / ${totalArticles} Artikel`;
if (statsEl) statsEl.textContent = LangManager.t('map.stats', {places: locations.length, articles: totalArticles});
if (emptyEl) emptyEl.style.display = 'none';
}
return;
@@ -638,7 +617,7 @@ const UI = {
// Statistik
const totalArticles = locations.reduce((s, l) => s + l.article_count, 0);
if (statsEl) statsEl.textContent = `${locations.length} Orte / ${totalArticles} Artikel`;
if (statsEl) statsEl.textContent = LangManager.t('map.stats', {places: locations.length, articles: totalArticles});
// Container-Hoehe sicherstellen (Leaflet braucht px-Hoehe)
if (container.offsetHeight < 50) {
@@ -691,11 +670,11 @@ const UI = {
popupHtml += `<div class="map-popup-title">${this.escape(loc.location_name)}`;
if (loc.country_code) popupHtml += ` <span class="map-popup-cc">${this.escape(loc.country_code)}</span>`;
popupHtml += `</div>`;
popupHtml += `<div class="map-popup-count">${loc.article_count} Artikel</div>`;
popupHtml += `<div class="map-popup-count">${LangManager.t('map.articles', {n: loc.article_count})}</div>`;
popupHtml += `<div class="map-popup-articles">`;
const maxShow = 5;
loc.articles.slice(0, maxShow).forEach(art => {
const headline = this.escape(art.headline || 'Ohne Titel');
const headline = this.escape(art.headline || LangManager.t('map.no_title'));
const source = this.escape(art.source || '');
if (art.source_url) {
popupHtml += `<a href="${this.escape(art.source_url)}" target="_blank" rel="noopener" class="map-popup-article">${headline} <span class="map-popup-source">${source}</span></a>`;
@@ -704,7 +683,7 @@ const UI = {
}
});
if (loc.articles.length > maxShow) {
popupHtml += `<div class="map-popup-more">+${loc.articles.length - maxShow} weitere</div>`;
popupHtml += `<div class="map-popup-more">${LangManager.t('map.more', {n: loc.articles.length - maxShow})}</div>`;
}
popupHtml += `</div></div>`;
@@ -744,8 +723,8 @@ const UI = {
if (layer instanceof L.TileLayer) this._map.removeLayer(layer);
});
// Deutsche OSM-Kacheln: deutsche Ortsnamen, einheitlich fuer beide Themes
const tileUrl = 'https://tile.openstreetmap.de/{z}/{x}/{y}.png';
// OSM-Kacheln je nach Sprache (DE: deutsche Ortsnamen, EN: internationale)
const tileUrl = LangManager.mapTileUrl();
const attribution = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
L.tileLayer(tileUrl, { attribution, maxZoom: 18 }).addTo(this._map);