Livrare Lot 3 (Frontend): aplicație web, aplicație mobilă Android, extensie browser

- Surse complete web (React/Vite) + mobil (React Native/Expo) + extensie (MV3)
- Documentație de livrare: ghid utilizare, matrice trasabilitate cerințe, raport testare furnizor
- Artefacte binare: imagine Docker didi-frontend:lot3-1.0, APK, extensie v3.2.6 + SHA256SUMS
- Configurare adresă platformă externalizată (build args / .env / config.js)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Top Clossers 2026-07-17 12:33:59 +03:00
commit cec967f953
321 changed files with 80506 additions and 0 deletions

490
extension/render.js Normal file
View file

@ -0,0 +1,490 @@
/**
* DIDI Extension Shared analysis renderer (vanilla JS, ~80% web-app parity).
*
* Drop-in for popup tab, content modal, history page. Backend-FLAT shape:
* `analysis.{verdict, techniques, ai_tampered, claims, source_assessment, ...}`.
*
* Mirrors web app PipelineAnalysis: same RISK_COLORS map (by `verdict.risk_category_color`,
* NOT by score), same HeadlineCard + ActionCallout + per-component cards layout.
*
* Exposes globally as `globalThis.DidiRender = { renderAnalysisHTML, ... }`,
* accessible as `window.DidiRender` (DOM contexts) or `self.DidiRender` (service worker).
*/
(function () {
'use strict';
// === Risk color map — identical to web app utils.ts ===
const RISK_COLORS = {
green: '#22c55e',
lightgreen: '#84cc16',
yellow: '#eab308',
orange: '#f97316',
red: '#ef4444',
darkred: '#dc2626',
};
const FALLBACK_ACCENT = '#94a3b8';
// Severity / probability colors for inner cards
const sevColor = (s) => (s >= 70 ? '#ef4444' : s >= 40 ? '#f97316' : '#eab308');
const probColor = (p) => (p >= 80 ? '#ef4444' : p >= 60 ? '#f97316' : p >= 40 ? '#eab308' : '#22c55e');
const stanceColor = (s) => (s === 'SUPPORTS' ? '#22c55e' : s === 'CONTRADICTS' ? '#ef4444' : '#94a3b8');
const claimStatusColor = (st) => ({ VT: '#22c55e', LT: '#84cc16', VF: '#ef4444', LF: '#f97316', UV: '#eab308', OP: '#94a3b8', NV: '#6b7280' }[st] || '#94a3b8');
// Category emoji icon (extension keeps emoji vs web's lucide for 80% parity)
const categoryIcon = (color) => {
if (color === 'red' || color === 'darkred') return '⛔';
if (color === 'orange') return '⚠️';
if (color === 'yellow') return '';
if (color === 'green' || color === 'lightgreen') return '🛡️';
return '';
};
const escapeHtml = (s) => String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
const safeHostname = (url) => {
try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return ''; }
};
const truncate = (s, n) => (s && s.length > n ? s.substring(0, n) + '…' : s || '');
// Convert SCREAMING_SNAKE_CASE / snake_case to "Sentence case" for display.
// Leaves already-readable strings (with spaces or mixed case) untouched.
const humanize = (s) => {
if (!s || typeof s !== 'string') return s;
if (!/[_]/.test(s) && /[a-z]/.test(s)) return s; // already mixed-case prose
const lower = s.replace(/_/g, ' ').toLowerCase().trim();
return lower.charAt(0).toUpperCase() + lower.slice(1);
};
// === i18n — oglindă a web app (utils/i18n-enums.ts + utils/i18n-fields.ts) ===
// Extensia e în română; enum-urile backend fără câmpuri _ro se traduc prin ENUM_RO.
const ENUM_RO = {
DISINFORMATION: 'Dezinformare', UNRELIABLE: 'Nedemn de încredere', QUESTIONABLE: 'Îndoielnic',
UNCERTAIN: 'Incert', MIXED: 'Mixt', MOSTLY_TRUSTWORTHY: 'Preponderent credibil',
MOSTLY_RELIABLE: 'Preponderent credibil', TRUSTWORTHY: 'Demn de încredere', RELIABLE: 'De încredere',
INCONCLUSIVE: 'Neconcludent',
CRITICAL: 'Critic', VERY_HIGH: 'Foarte ridicat', HIGH: 'Ridicat', MODERATE: 'Moderat',
MEDIUM: 'Mediu', LOW: 'Scăzut', VERY_LOW: 'Foarte scăzut',
LIKELY_AI: 'Probabil AI', POSSIBLY_AI: 'Posibil AI', UNLIKELY_AI: 'Improbabil AI',
POSSIBLY_HUMAN: 'Posibil uman', LIKELY_HUMAN: 'Probabil uman', HUMAN: 'Uman',
TRUSTED: 'De încredere', NEUTRAL: 'Neutru', SUSPICIOUS: 'Suspect', UNTRUSTED: 'Nedemn de încredere',
SUPPORTS: 'Susține', CONTRADICTS: 'Contrazice',
official: 'Oficial', news: 'Știri', blog: 'Blog', unknown: 'Necunoscut',
NONE: 'Niciuna', CAUTION: 'Precauție', WARNING: 'Avertizare', URGENT: 'Urgent',
ESCALATE: 'Escaladează', VIRAL: 'Viral',
};
const enumLabel = (v) => (v && ENUM_RO[v]) || humanize(v || '');
// Câmp localizat cu convenția backend _ro/_en (ex. technique_name_ro).
const loc = (obj, base, fallback) => {
if (!obj) return '';
return obj[`${base}_ro`] || (fallback ? obj[`${fallback}_ro`] : '') ||
obj[`${base}_en`] || (fallback ? obj[`${fallback}_en`] : '') ||
obj[base] || (fallback ? obj[fallback] : '') || '';
};
// Nume de tehnică lizibil: preferă câmpurile localizate; dacă rămâne cheia brută
// din taxonomie (ex. "content.type_misleading"), taie prefixul de dimensiune și umanizează.
const techniqueName = (tk) => {
const named = loc(tk, 'technique_name', 'name');
if (named && !/^[a-z_]+\.[a-z_.]+$/.test(named)) return named;
const raw = named || tk.name || '';
const lastSegment = raw.split('.').pop() || raw;
return humanize(lastSegment);
};
const INPUT_TYPE_RO = { text: 'TEXT', url: 'URL', image: 'IMAGINE', audio: 'AUDIO', video: 'VIDEO' };
// === Gauge — circular SVG, identical structure to web Gauge component ===
function gaugeSVG(scorePercent, accent) {
const R = 80;
const C = 2 * Math.PI * R; // circumference
const offset = C * (1 - Math.max(0, Math.min(100, scorePercent)) / 100);
return `
<div style="width:168px;height:168px;position:relative;color:${accent};flex-shrink:0;">
<svg viewBox="0 0 168 168" style="width:100%;height:100%;transform:rotate(-90deg);">
<circle cx="84" cy="84" r="${R}" fill="none" stroke="currentColor" stroke-width="6" opacity="0.12"/>
<circle cx="84" cy="84" r="${R}" fill="none" stroke="currentColor" stroke-width="6"
stroke-linecap="round" stroke-dasharray="${C.toFixed(2)}" stroke-dashoffset="${offset.toFixed(2)}"
style="transition:stroke-dashoffset 0.9s cubic-bezier(0.2,0.8,0.2,1);"/>
</svg>
<div style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;">
<div style="font-size:42px;font-weight:700;color:#fff;font-family:Inter,system-ui,sans-serif;line-height:1;">${Math.round(scorePercent)}</div>
<div style="font-size:11px;color:rgba(255,255,255,0.55);text-transform:uppercase;letter-spacing:1.5px;margin-top:6px;font-weight:600;">/ 100</div>
</div>
</div>`;
}
// === HeadlineCard — gradient bg + gauge + category info ===
function headlineCard(verdict, accent) {
const cat = enumLabel(verdict.risk_category || 'UNKNOWN');
const lvl = enumLabel(verdict.risk_level || '');
const conf = verdict.confidence != null ? `certitudine ${Math.round(verdict.confidence)}%` : '';
const score = verdict.risk_score ?? 0;
const descr = [lvl, conf].filter(Boolean).join(' · ');
// Explicația se afișează integral (ca în web) — trunchierea tăia exact nuanțele
// de tip „afirmațiile sunt adevărate, dar prezentarea e falsificată”.
const tldrShort = (verdict.explanation_ro || verdict.explanation_en || '').trim();
return `
<div style="display:flex;align-items:stretch;gap:28px;padding:32px 36px;
background:linear-gradient(180deg, ${accent}1a, rgba(255,255,255,0.025));
border:1px solid ${accent}33;border-radius:20px;position:relative;overflow:hidden;
font-family:Inter,system-ui,sans-serif;">
<div style="display:flex;align-items:center;justify-content:center;">
${gaugeSVG(score, accent)}
</div>
<div style="flex:1;display:flex;flex-direction:column;justify-content:center;gap:10px;min-width:0;">
<div style="font-size:11px;color:rgba(255,255,255,0.55);text-transform:uppercase;letter-spacing:1.6px;font-weight:600;">RISC ANALIZAT</div>
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:10px;background:${accent}26;color:${accent};font-size:18px;flex-shrink:0;">${categoryIcon(verdict.risk_category_color)}</span>
<span style="font-size:24px;font-weight:700;color:#fff;line-height:1.2;">${escapeHtml(cat)}</span>
${descr ? `<span style="font-size:13px;color:rgba(255,255,255,0.6);">${escapeHtml(descr)}</span>` : ''}
</div>
${tldrShort ? `<div style="font-family:Merriweather,Georgia,serif;font-style:italic;color:rgba(255,255,255,0.78);font-size:14px;line-height:1.55;margin-top:4px;">${escapeHtml(tldrShort)}</div>` : ''}
</div>
</div>`;
}
// === ActionCallout — recommended_action with severity-tinted accent ===
function actionCallout(verdict, accent) {
const action = (verdict.recommended_action || '').trim();
if (!action) return '';
const sev = verdict.severity || '';
const eyebrow = sev === 'CRITICAL' || sev === 'HIGH' ? 'NU DISTRIBUI'
: sev === 'MEDIUM' ? 'CITEȘTE CRITIC' : 'NOTĂ';
return `
<div style="display:flex;gap:20px;align-items:flex-start;padding:22px 26px;
background:linear-gradient(135deg, ${accent}40, ${accent}1a);
border:1px solid ${accent}66;border-radius:16px;
font-family:Inter,system-ui,sans-serif;">
<span style="flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;
width:44px;height:44px;border-radius:14px;background:${accent}33;color:${accent};font-size:22px;"></span>
<div style="flex:1;min-width:0;">
<div style="font-size:11px;color:${accent};text-transform:uppercase;letter-spacing:1.4px;font-weight:700;margin-bottom:6px;">${escapeHtml(eyebrow)}</div>
<div style="font-size:15px;color:#fff;font-weight:600;line-height:1.5;">${escapeHtml(enumLabel(action))}</div>
</div>
</div>`;
}
// === Per-component cards ===
// Subtle technical footer shown at the bottom of each component card.
// Lists model(s) used, total LLM duration. Empty string if no signals.
function metaFooter(items) {
const parts = items.filter(Boolean);
if (!parts.length) return '';
return `<div style="margin-top:14px;padding-top:10px;border-top:1px solid rgba(255,255,255,0.05);
font-size:10px;color:rgba(255,255,255,0.4);text-transform:uppercase;letter-spacing:0.5px;
display:flex;flex-wrap:wrap;gap:14px;">
${parts.map((p) => `<span>${escapeHtml(p)}</span>`).join('')}
</div>`;
}
function techniquesCard(tech) {
if (!tech) return '';
const list = tech.techniques_detected || [];
if (list.length === 0) return '';
const score = Math.round((tech.manipulation_score || 0));
const items = list.slice(0, 8).map((tk) => {
const sc = sevColor(tk.severity || 0);
const conf = tk.confidence ?? '';
const sub = loc(tk, 'subdimension_name', 'subdimension') || loc(tk, 'dimension_name', 'dimension');
const ev = tk.evidence ? truncate(tk.evidence, 140) : '';
return `
<div style="background:rgba(255,255,255,0.04);border-left:3px solid ${sc};padding:10px 14px;border-radius:8px;margin-bottom:8px;">
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;">
<span style="color:#fff;font-weight:600;font-size:13px;">${escapeHtml(techniqueName(tk))}</span>
<span style="background:${sc}26;border:1px solid ${sc};color:${sc};padding:2px 9px;border-radius:5px;font-size:11px;font-weight:700;flex-shrink:0;">${tk.severity || 0}%</span>
</div>
<div style="color:rgba(255,255,255,0.5);font-size:11px;margin-top:4px;">${escapeHtml(humanize(sub))}${conf !== '' ? ` · încredere ${conf}%` : ''}</div>
${ev ? `<div style="color:rgba(255,255,255,0.6);font-size:11px;margin-top:6px;font-style:italic;border-top:1px solid rgba(255,255,255,0.06);padding-top:6px;">"${escapeHtml(ev)}"</div>` : ''}
</div>`;
}).join('');
const more = list.length > 8 ? `<div style="text-align:center;font-size:11px;color:rgba(255,255,255,0.4);margin-top:6px;">+ încă ${list.length - 8} tehnici</div>` : '';
const dims = (tech.dimensions_affected || []).slice(0, 6).map((d) => humanize(String(d).split('.').pop())).join(', ');
const footer = metaFooter([
tech.llm_screening && `screening: ${tech.llm_screening}`,
tech.llm_deep && `deep: ${tech.llm_deep}`,
tech.total_duration_ms && `${(tech.total_duration_ms / 1000).toFixed(1)}s`,
dims && `dimensiuni: ${dims}`,
]);
return componentCardWrap('Tehnici de manipulare', `${list.length} detectate`, score, '#f97316', items + more + footer);
}
function aiTamperedCard(ai) {
if (!ai || ai.ai_probability == null) return '';
const prob = Math.round(ai.ai_probability);
const accent = probColor(prob);
const verdict = enumLabel(ai.verdict || 'Unknown');
const disclosure = ai.disclosure_detected
? `<div style="font-size:12px;color:#22c55e;margin-top:8px;">Declarație de utilizare AI detectată${ai.disclosure_text ? ': "' + escapeHtml(truncate(ai.disclosure_text, 80)) + '"' : ''}</div>`
: '';
const inner = `
<div style="display:flex;align-items:center;gap:18px;padding:8px 4px;">
<div style="width:64px;height:64px;border-radius:50%;border:3px solid ${accent};display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700;color:${accent};flex-shrink:0;">${prob}%</div>
<div style="flex:1;">
<div style="color:#fff;font-weight:600;font-size:14px;">${escapeHtml(verdict)}</div>
<div style="color:rgba(255,255,255,0.55);font-size:12px;margin-top:2px;">Probabilitate de conținut generat de AI</div>
${disclosure}
</div>
</div>`;
const cats = (ai.categories_affected || []).slice(0, 5).join(', ');
const footer = metaFooter([
ai.content_type && `tip: ${ai.content_type}`,
cats && `categorii: ${cats}`,
ai.indicators_count != null && `${ai.indicators_count} indicatori`,
ai.llm_screening && `screening: ${ai.llm_screening}`,
ai.llm_deep && ai.llm_deep !== 'none' && `deep: ${ai.llm_deep}`,
ai.total_duration_ms && `${(ai.total_duration_ms / 1000).toFixed(1)}s`,
]);
return componentCardWrap('Detecție AI', verdict, prob, accent, inner + footer);
}
function claimsCard(c) {
if (!c || !c.claims_verified) return '';
const list = c.claims_verified || [];
if (list.length === 0) return '';
const cred = c.credibility_score != null ? Math.round(c.credibility_score) : null;
const accent = cred == null ? '#94a3b8' : (cred >= 70 ? '#22c55e' : cred >= 40 ? '#eab308' : '#ef4444');
const summary = `
<div style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap;">
${c.verified_true ? `<span style="background:rgba(34,197,94,0.18);border:1px solid #22c55e;color:#22c55e;padding:4px 10px;border-radius:5px;font-size:11px;font-weight:600;">✓ Adevărate: ${c.verified_true}</span>` : ''}
${c.verified_false ? `<span style="background:rgba(239,68,68,0.18);border:1px solid #ef4444;color:#ef4444;padding:4px 10px;border-radius:5px;font-size:11px;font-weight:600;">✗ False: ${c.verified_false}</span>` : ''}
${c.unverified ? `<span style="background:rgba(234,179,8,0.18);border:1px solid #eab308;color:#eab308;padding:4px 10px;border-radius:5px;font-size:11px;font-weight:600;">? Neverificate: ${c.unverified}</span>` : ''}
${c.opinions ? `<span style="background:rgba(148,163,184,0.18);border:1px solid #94a3b8;color:#94a3b8;padding:4px 10px;border-radius:5px;font-size:11px;font-weight:600;">Opinii: ${c.opinions}</span>` : ''}
</div>`;
const items = list.slice(0, 6).map((cl) => {
const sc = claimStatusColor(cl.status);
const sources = (cl.sources || []).slice(0, 3);
const srcRow = sources.length ? `
<div style="margin-top:8px;border-top:1px solid rgba(255,255,255,0.06);padding-top:8px;display:flex;flex-wrap:wrap;gap:8px;">
${sources.map((s) => {
const stc = stanceColor(s.stance);
const dom = safeHostname(s.url);
return `<a href="${escapeHtml(s.url)}" target="_blank" rel="noopener" style="font-size:11px;color:${stc};text-decoration:none;">${escapeHtml(dom)} <span style="opacity:0.6;">(${escapeHtml(enumLabel(s.stance || 'NEUTRAL'))})</span></a>`;
}).join('')}
${(cl.sources || []).length > 3 ? `<span style="font-size:11px;color:rgba(255,255,255,0.4);">+ încă ${(cl.sources || []).length - 3}</span>` : ''}
</div>` : '';
return `
<div style="background:rgba(255,255,255,0.04);border-left:3px solid ${sc};padding:12px 14px;border-radius:8px;margin-bottom:8px;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;">
<span style="color:#e5e7eb;font-size:13px;line-height:1.5;flex:1;">${escapeHtml(cl.text || '')}</span>
<span style="background:${sc}26;border:1px solid ${sc};color:${sc};padding:3px 9px;border-radius:5px;font-size:10px;font-weight:700;white-space:nowrap;flex-shrink:0;">${escapeHtml(loc(cl, 'status_name') || enumLabel(cl.status || '?'))}</span>
</div>
${cl.reasoning ? `<div style="color:rgba(255,255,255,0.55);font-size:12px;margin-top:8px;line-height:1.5;">${escapeHtml(truncate(cl.reasoning, 220))}</div>` : ''}
${srcRow}
</div>`;
}).join('');
const more = list.length > 6 ? `<div style="text-align:center;font-size:11px;color:rgba(255,255,255,0.4);margin-top:6px;">+ încă ${list.length - 6} afirmații</div>` : '';
const meta = cred != null ? `credibilitate ${cred}%` : '';
const footer = metaFooter([
c.llm_extraction && `extragere: ${c.llm_extraction}`,
c.llm_verification && `verificare: ${c.llm_verification}`,
c.web_searches_made != null && `${c.web_searches_made} căutări web`,
c.total_duration_ms && `${(c.total_duration_ms / 1000).toFixed(1)}s`,
]);
return componentCardWrap('Verificarea afirmațiilor', meta, cred, accent, summary + items + more + footer);
}
function sourceCard(s) {
if (!s) return '';
// V3 source_assessment OR legacy domain. `s.domain` may be a string (legacy)
// or an object (extended source assessment with sub-fields) — never render
// a raw object via template interpolation.
const trust = s.trust_score ?? s.credibility_score ?? null;
const verdict = enumLabel(s.verdict || s.assessment_verdict || '');
let domain = '';
if (typeof s.domain === 'string' && s.domain) domain = s.domain;
else if (s.publication?.url) domain = safeHostname(s.publication.url);
else if (s.url) domain = safeHostname(s.url);
if (!verdict && !domain && trust == null) return '';
const accent = trust == null ? '#94a3b8' : (trust >= 70 ? '#22c55e' : trust >= 40 ? '#eab308' : '#ef4444');
const flags = (s.red_flags || []).slice(0, 5);
const warnings = (s.warnings || []).slice(0, 5);
// Pull rich publication/author/platform metadata from extended source assessment.
const pub = s.publication || {};
const author = s.author || {};
const platform = s.platform || {};
const pubName = pub.name || pub.publisher || '';
const pubTier = pub.tier || pub.credibility || '';
const authorName = author.name || author.author_name || '';
const authorClass = author.classification || author.class || author.credibility || '';
const platformName = platform.name || platform.platform_name || '';
const detailCells = [
domain && { label: 'Domeniu', value: domain, color: '#fff' },
verdict && { label: 'Verdict', value: verdict, color: accent },
s.age_category && { label: 'Vechime', value: humanize(s.age_category), color: '#fff' },
pubName && { label: 'Publicație', value: pubName + (pubTier ? ` · ${pubTier}` : ''), color: '#fff' },
authorName && { label: 'Autor', value: authorName + (authorClass ? ` · ${authorClass}` : ''), color: '#fff' },
platformName && { label: 'Platformă', value: platformName, color: '#fff' },
].filter(Boolean);
const inner = `
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:10px;margin-bottom:${flags.length || warnings.length ? 14 : 0}px;">
${detailCells.map(c => `<div style="background:rgba(255,255,255,0.04);padding:10px 12px;border-radius:8px;">
<div style="font-size:10px;color:rgba(255,255,255,0.45);text-transform:uppercase;letter-spacing:1px;">${escapeHtml(c.label)}</div>
<div style="color:${c.color};font-size:13px;font-weight:600;margin-top:2px;word-break:break-word;">${escapeHtml(String(c.value))}</div>
</div>`).join('')}
</div>
${flags.length ? `<div style="margin-bottom:8px;"><div style="font-size:11px;color:#ef4444;font-weight:600;margin-bottom:6px;text-transform:uppercase;letter-spacing:0.5px;">Semnale de alarmă</div>${flags.map(f => {
const raw = typeof f === 'string' ? f : (f.description || f.flag || JSON.stringify(f));
return `<div style="font-size:12px;color:rgba(255,255,255,0.75);background:rgba(239,68,68,0.08);padding:6px 10px;border-radius:5px;margin-bottom:4px;">${escapeHtml(humanize(raw))}</div>`;
}).join('')}</div>` : ''}
${warnings.length ? `<div><div style="font-size:11px;color:#f97316;font-weight:600;margin-bottom:6px;text-transform:uppercase;letter-spacing:0.5px;">Avertismente</div>${warnings.map(w => {
const raw = typeof w === 'string' ? w : (w.description || w.warning || JSON.stringify(w));
return `<div style="font-size:12px;color:rgba(255,255,255,0.75);background:rgba(249,115,22,0.08);padding:6px 10px;border-radius:5px;margin-bottom:4px;">${escapeHtml(humanize(raw))}</div>`;
}).join('')}</div>` : ''}`;
const footer = metaFooter([
s.llm_extraction && `extragere: ${s.llm_extraction}`,
s.llm_evaluation && `evaluare: ${s.llm_evaluation}`,
s.total_duration_ms && `${(s.total_duration_ms / 1000).toFixed(1)}s`,
]);
return componentCardWrap('Evaluarea sursei', verdict, trust, accent, inner + footer);
}
// Generic card wrapper used by all per-component renderers.
function componentCardWrap(title, sublabel, score, accent, innerHTML) {
const scoreBlock = score != null
? `<span style="background:${accent}26;border:1px solid ${accent};color:${accent};padding:3px 11px;border-radius:6px;font-size:12px;font-weight:700;">${score}%</span>`
: '';
return `
<div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06);border-radius:14px;padding:20px 22px;font-family:Inter,system-ui,sans-serif;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;gap:8px;">
<div style="font-size:14px;font-weight:600;color:#fff;">${escapeHtml(title)}</div>
<div style="display:flex;align-items:center;gap:8px;">
${sublabel ? `<span style="font-size:12px;color:rgba(255,255,255,0.5);">${escapeHtml(sublabel)}</span>` : ''}
${scoreBlock}
</div>
</div>
${innerHTML}
</div>`;
}
// === Input preview — small card at top showing what was analyzed ===
function inputPreview(analysis) {
const inputType = analysis.input_type || analysis.analysis_type || '';
const text = analysis.input_text;
const url = analysis.input_url;
const media = analysis.input_media_url;
if (!text && !url && !media) return '';
const label = `CONȚINUT (${INPUT_TYPE_RO[inputType] || (inputType || 'INPUT').toUpperCase()})`;
let body = '';
if (text) body = `<div style="color:rgba(255,255,255,0.85);font-size:13px;line-height:1.5;">${escapeHtml(truncate(text, 280))}</div>`;
else if (url) body = `<a href="${escapeHtml(url)}" target="_blank" rel="noopener" style="color:#1fb6bd;font-size:13px;word-break:break-all;text-decoration:none;">${escapeHtml(url)}</a>`;
else if (media && (inputType === 'image' || /\.(png|jpe?g|gif|webp)(\?|$)/i.test(media))) {
// Imagine analizată → miniatură, nu URL-ul brut de storage
body = `<a href="${escapeHtml(media)}" target="_blank" rel="noopener" style="display:inline-block;">
<img src="${escapeHtml(media)}" alt="Imaginea analizată"
style="max-width:100%;max-height:180px;border-radius:8px;border:1px solid rgba(255,255,255,0.1);display:block;"></a>`;
}
else if (media) body = `<a href="${escapeHtml(media)}" target="_blank" rel="noopener" style="color:#1fb6bd;font-size:13px;text-decoration:none;">Deschide fișierul analizat</a>`;
return `
<div style="background:rgba(255,255,255,0.025);border:1px solid rgba(255,255,255,0.05);border-radius:12px;padding:14px 18px;font-family:Inter,system-ui,sans-serif;">
<div style="font-size:10px;color:rgba(255,255,255,0.45);text-transform:uppercase;letter-spacing:1.4px;font-weight:600;margin-bottom:6px;">${escapeHtml(label)}</div>
${body}
</div>`;
}
// === Main entry — return the full inner HTML for the analysis panel ===
function renderAnalysisHTML(analysis) {
if (!analysis || typeof analysis !== 'object') {
return '<div style="padding:24px;color:#ef4444;">Invalid analysis payload.</div>';
}
const verdict = analysis.verdict || {};
const accent = RISK_COLORS[verdict.risk_category_color] || FALLBACK_ACCENT;
const dur = analysis.total_duration_ms;
const inputType = analysis.input_type || analysis.analysis_type || 'text';
const sections = [
inputPreview(analysis),
headlineCard(verdict, accent),
actionCallout(verdict, accent),
techniquesCard(analysis.techniques),
aiTamperedCard(analysis.ai_tampered),
claimsCard(analysis.claims),
sourceCard(analysis.source_assessment || analysis.domain),
].filter(Boolean).join('');
const sid = analysis.session_id ? ` · sesiune ${analysis.session_id.substring(0, 8)}` : '';
const meta = `
<div style="display:flex;justify-content:space-between;align-items:center;font-size:12px;color:rgba(255,255,255,0.45);margin-top:16px;font-family:Inter,system-ui,sans-serif;">
<span>${new Date().toLocaleString('ro-RO')} · ${escapeHtml((INPUT_TYPE_RO[inputType] || inputType).toLowerCase())}${sid}</span>
${dur ? `<span>${(dur / 1000).toFixed(1)}s</span>` : ''}
</div>`;
return `<div style="display:flex;flex-direction:column;gap:16px;font-family:Inter,system-ui,sans-serif;">${sections}${meta}</div>`;
}
// === Standalone tab page — full <html> shell wrapping the panel ===
function renderStandalonePage(analysis) {
const inner = renderAnalysisHTML(analysis);
return `<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<title>didi · raport de analiză</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Merriweather:ital@1&display=swap" rel="stylesheet">
<style>
*,*::before,*::after { box-sizing: border-box; margin: 0; padding: 0; }
body { background:#0d1424; color:#fff; padding:32px 16px; min-height:100vh;
font-family: Inter, system-ui, -apple-system, sans-serif; }
.container { max-width: 820px; margin: 0 auto; }
h1 { font-size: 28px; font-weight: 700; margin-bottom: 6px;
background: linear-gradient(135deg, #009198 0%, #1fb6bd 100%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.subtitle { color: rgba(255,255,255,0.5); margin-bottom: 24px; font-size: 13px; }
a { color: inherit; }
</style>
</head>
<body>
<div class="container">
<h1>didi · raport de analiză</h1>
<div class="subtitle">Rezultatul analizei anti-dezinformare</div>
${inner}
</div>
</body>
</html>`;
}
// === In-page modal — open from content script (overlay + close handlers) ===
function renderModalHTML(analysis) {
return `
<div style="position:fixed;inset:0;background:rgba(13,20,36,0.88);z-index:99999999;
display:flex;align-items:center;justify-content:center;animation:didiFadeIn 240ms ease;">
<div style="background:#0d1424;border:1px solid rgba(0,145,152,0.25);border-radius:20px;
max-width:760px;width:92%;max-height:92vh;overflow-y:auto;
box-shadow:0 24px 80px rgba(0,0,0,0.7);padding:28px;
font-family:Inter,system-ui,sans-serif;animation:didiSlideUp 320ms cubic-bezier(0.2,0.8,0.2,1);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;">
<h2 style="font-size:22px;font-weight:700;background:linear-gradient(135deg,#009198,#1fb6bd);
-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;">didi · raport de analiză</h2>
<button class="didi-modal-close" style="background:rgba(255,255,255,0.06);border:none;color:#fff;
font-size:22px;width:34px;height:34px;border-radius:9px;cursor:pointer;">×</button>
</div>
${renderAnalysisHTML(analysis)}
<div style="margin-top:18px;padding-top:18px;border-top:1px solid rgba(255,255,255,0.06);">
<button class="didi-modal-gotit" style="width:100%;padding:13px;background:linear-gradient(135deg,#009198,#009198);
border:none;border-radius:10px;color:#fff;font:inherit;font-size:14px;font-weight:600;cursor:pointer;">Am înțeles</button>
</div>
</div>
</div>`;
}
// Public API — exposed on globalThis so the same file works in every context:
// - popup.html / history.html: window === globalThis (DOM page)
// - content scripts (isolated world): window === globalThis
// - service worker (background.js importScripts): self === globalThis (NO window)
globalThis.DidiRender = {
RISK_COLORS,
renderAnalysisHTML,
renderStandalonePage,
renderModalHTML,
escapeHtml,
};
})();