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

View file

@ -0,0 +1,357 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import styled from '@emotion/styled';
import {
Cpu, ShieldCheck, ShieldAlert, AlertOctagon, AlertTriangle, AlertCircle,
FileText, Image as ImageIcon, Sparkles, ChevronDown, Plus,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { typography } from '../../../theme';
import { localized } from '../../../utils/i18n-fields';
import { enumLabel } from '../../../utils/i18n-enums';
import type { AiTamperedResult, AiIndicatorDetected } from '../../../types/analysis-session';
import { HeadlineCard } from './HeadlineCard';
import { FindingCard, type FindingAccent } from './FindingCard';
import { SectionDivider } from './SectionDivider';
import {
StatBlock, ChipsRow, Chip,
CollapseBtn, CollapseBtnLeft, CollapseBtnChevron,
} from '../styles';
const probAccent = (p: number): string =>
p >= 80 ? '#ef4444' : p >= 60 ? '#f97316' : p >= 40 ? '#eab308' : '#22c55e';
const probIcon = (p: number): LucideIcon => {
if (p >= 80) return AlertOctagon;
if (p >= 60) return AlertTriangle;
if (p >= 40) return Cpu;
return ShieldCheck;
};
const indicatorAccent = (confidence: number): FindingAccent => {
if (confidence >= 80) return 'critical';
if (confidence >= 60) return 'warning';
if (confidence >= 40) return 'info';
return 'neutral';
};
const verdictLabelKey = (verdict: string): string => {
switch (verdict) {
case 'LIKELY_AI': return 'aiTamper.verdicts.likelyAi';
case 'POSSIBLY_AI': return 'aiTamper.verdicts.possiblyAi';
case 'UNLIKELY_AI': return 'aiTamper.verdicts.unlikelyAi';
case 'LIKELY_HUMAN': return 'aiTamper.verdicts.likelyHuman';
case 'HUMAN': return 'aiTamper.verdicts.humanWritten';
default: return '';
}
};
const CATEGORY_LABEL_KEYS: Record<string, string> = {
T1: 'aiTamper.categories.writingStyle',
T2: 'aiTamper.categories.contentPatterns',
T3: 'aiTamper.categories.structuralAnalysis',
T4: 'aiTamper.categories.statisticalMarkers',
T5: 'aiTamper.categories.explicitSignals',
};
const DEFAULT_VISIBLE_INDICATORS = 5;
/** Display-friendly shape that flattens AiTamperedResult variations across modes. */
export interface AiDisplayResult {
verdict: string;
ai_probability: number;
disclosure_detected?: boolean;
disclosure_explicit?: boolean;
disclosure_text?: string | null;
/** Quick mode text indicators */
indicators_found?: string[];
/** Deep mode text + audio/video indicators */
indicators_detected?: AiIndicatorDetected[];
categories_affected?: string[];
coupling_context?: AiTamperedResult['coupling_context'];
image_indicators?: string[];
image_evidence?: string;
model_used?: string;
transcript?: string;
}
/** Build AiDisplayResult from a raw AiTamperedResult + optional input meta. */
export function toAiDisplayResult(
r: AiTamperedResult,
ctx: { inputType?: string; inputText?: string | null } = {},
): AiDisplayResult {
const out: AiDisplayResult = {
verdict: r.verdict,
ai_probability: r.ai_probability,
disclosure_detected: r.disclosure_detected,
disclosure_explicit: r.disclosure_explicit,
disclosure_text: r.disclosure_text,
};
if (r.indicators_detected && r.indicators_detected.length > 0) {
out.indicators_detected = r.indicators_detected;
out.categories_affected = r.categories_affected;
out.coupling_context = r.coupling_context;
}
if (r.image_analysis?.indicators && r.image_analysis.indicators.length > 0) {
out.image_indicators = r.image_analysis.indicators;
out.image_evidence = r.image_analysis.evidence;
out.model_used = r.image_analysis.model_used;
}
if (ctx.inputText && (ctx.inputType === 'audio' || ctx.inputType === 'video')) {
out.transcript = ctx.inputText;
}
return out;
}
interface Props {
result: AiDisplayResult;
isRo: boolean;
/** When set, a "try deep analysis" CTA is shown inside the clean-content card. Used by the live standalone where the user can re-run in deep mode. History view should omit this. */
onTryDeepScan?: () => void;
}
export const AiResults: React.FC<Props> = ({ result, isRo, onTryDeepScan }) => {
const { t } = useTranslation();
const [indicatorsExpand, setIndicatorsExpand] = useState(false);
const probability = result.ai_probability || 0;
const verdict = result.verdict || '';
const accent = probAccent(probability);
const Icon = probIcon(probability);
const verdictText = verdictLabelKey(verdict)
? t(verdictLabelKey(verdict))
: (verdict || '').replace(/_/g, ' ');
const tldr = (() => {
const indCount = result.indicators_detected?.length
|| result.indicators_found?.length
|| result.image_indicators?.length
|| 0;
const disclosureNote = result.disclosure_detected === true
? (isRo ? ' cu declarație AI' : ' with AI disclosure')
: result.disclosure_detected === false
? (isRo ? ' fără declarație AI' : ' without AI disclosure')
: '';
if (isRo) {
return `Probabilitate ${probability}% de generare AI${disclosureNote}${indCount > 0 ? ` · ${indCount} ${indCount === 1 ? 'indicator detectat' : 'indicatori detectați'}` : ''}.`;
}
return `${probability}% likelihood of AI generation${disclosureNote}${indCount > 0 ? ` · ${indCount} ${indCount === 1 ? 'indicator detected' : 'indicators detected'}` : ''}.`;
})();
const totalIndicators = result.indicators_detected?.length ?? 0;
const visibleCount = indicatorsExpand ? totalIndicators : Math.min(DEFAULT_VISIBLE_INDICATORS, totalIndicators);
const visibleIndicators = result.indicators_detected?.slice(0, visibleCount) ?? [];
const remainingIndicators = totalIndicators - visibleCount;
return (
<>
<HeadlineCard
accent={accent}
score={probability}
scoreLabel="% AI"
eyebrow={isRo ? 'Detectare AI' : 'AI detection'}
icon={Icon}
category={verdictText}
descriptor={
<>
{result.coupling_context?.for_verdict?.confidence_level && (
<>
<span>{isRo ? 'încredere' : 'confidence'} {enumLabel(result.coupling_context.for_verdict.confidence_level).toLowerCase()}</span>
{result.disclosure_detected !== undefined && <span className="sep">·</span>}
</>
)}
{result.disclosure_detected === true && (
<span>{isRo ? 'cu declarație AI' : 'AI disclosure'}</span>
)}
{result.disclosure_detected === false && (
<span>{isRo ? 'fără declarație AI' : 'no AI disclosure'}</span>
)}
</>
}
tldr={tldr}
/>
{(result.coupling_context?.for_verdict || (result.categories_affected && result.categories_affected.length > 0) || result.model_used) && (
<StatBlock>
<ChipsRow>
{result.coupling_context?.for_verdict?.undisclosed_ai && (
<Chip variant="warning">{t('aiTamper.undisclosedAi')}</Chip>
)}
{result.coupling_context?.for_verdict?.needs_manual_review && (
<Chip variant="warning">{t('aiTamper.needsManualReview')}</Chip>
)}
{result.categories_affected?.map(cat => (
<Chip key={cat} variant="violet">
{cat} · {CATEGORY_LABEL_KEYS[cat] ? t(CATEGORY_LABEL_KEYS[cat]) : cat}
</Chip>
))}
{result.model_used && (
<Chip variant="neutral">{result.model_used}</Chip>
)}
</ChipsRow>
</StatBlock>
)}
{result.disclosure_detected !== undefined && (
<FindingCard
icon={result.disclosure_detected ? ShieldCheck : ShieldAlert}
accent={result.disclosure_detected ? 'success' : 'neutral'}
eyebrow={isRo ? 'Declarație AI' : 'AI disclosure'}
headline={
result.disclosure_detected
? (result.disclosure_explicit ? t('aiTamper.explicitDisclosure') : t('aiTamper.implicitDisclosure'))
: t('aiTamper.noDisclosureDetected')
}
quote={result.disclosure_text || undefined}
/>
)}
{result.image_evidence && (
<FindingCard
icon={ImageIcon}
accent="info"
eyebrow={isRo ? 'Analiză imagine' : 'Image analysis'}
headline={t('aiTamper.analysisSummary')}
quote={result.image_evidence}
/>
)}
{result.transcript && (
<FindingCard
icon={FileText}
accent="info"
eyebrow={t('aiTamper.transcript')}
headline={isRo ? 'Conținut transcris' : 'Transcribed content'}
quote={result.transcript.length > 320
? result.transcript.slice(0, 320) + '…'
: result.transcript}
/>
)}
{result.indicators_found && result.indicators_found.length > 0 && (
<>
<SectionDivider
label={isRo ? 'Semnale detectate' : 'Signals detected'}
count={result.indicators_found.length}
/>
{result.indicators_found.map((ind, i) => (
<FindingCard
key={i}
icon={AlertCircle}
accent="info"
headline={ind}
/>
))}
</>
)}
{result.image_indicators && result.image_indicators.length > 0 && (
<>
<SectionDivider
label={t('aiTamper.imageIndicators')}
count={result.image_indicators.length}
/>
{result.image_indicators.map((ind, i) => (
<FindingCard
key={i}
icon={Sparkles}
accent="warning"
headline={ind}
/>
))}
</>
)}
{totalIndicators > 0 && (
<>
<SectionDivider
label={isRo ? 'Indicatori detectați' : 'Indicators detected'}
count={`${totalIndicators} ${isRo ? 'detectați' : 'detected'}${result.categories_affected?.length ? ` · ${result.categories_affected.join(' · ')}` : ''}`}
/>
{visibleIndicators.map(ind => {
const catLabel = CATEGORY_LABEL_KEYS[ind.category]
? t(CATEGORY_LABEL_KEYS[ind.category])
: ind.category;
const eyebrowParts = [
ind.category,
catLabel,
`${isRo ? 'încredere' : 'confidence'} ${ind.confidence}%`,
];
return (
<FindingCard
key={ind.id}
icon={AlertCircle}
accent={indicatorAccent(ind.confidence)}
eyebrow={eyebrowParts.join(' · ')}
headline={localized(ind, 'indicator_name', 'name')}
quote={ind.evidence}
/>
);
})}
{remainingIndicators > 0 && (
<CollapseBtn onClick={() => setIndicatorsExpand(true)}>
<CollapseBtnLeft>
<Plus size={16} strokeWidth={2} />
<span>
{isRo
? `Vezi celelalți ${remainingIndicators} ${remainingIndicators === 1 ? 'indicator' : 'indicatori'}`
: `Show ${remainingIndicators} more ${remainingIndicators === 1 ? 'indicator' : 'indicators'}`}
</span>
</CollapseBtnLeft>
<CollapseBtnChevron expanded={false}>
<ChevronDown size={14} strokeWidth={2.5} />
</CollapseBtnChevron>
</CollapseBtn>
)}
{indicatorsExpand && totalIndicators > DEFAULT_VISIBLE_INDICATORS && (
<CollapseBtn onClick={() => setIndicatorsExpand(false)}>
<CollapseBtnLeft>
<ChevronDown size={16} strokeWidth={2} />
<span>{isRo ? 'Ascunde' : 'Hide'}</span>
</CollapseBtnLeft>
<CollapseBtnChevron expanded={true}>
<ChevronDown size={14} strokeWidth={2.5} />
</CollapseBtnChevron>
</CollapseBtn>
)}
</>
)}
{(result.indicators_found?.length === 0
&& !result.indicators_detected
&& !result.image_indicators?.length) && (
<FindingCard
icon={ShieldCheck}
accent="success"
eyebrow={isRo ? 'Conținut curat' : 'Clean content'}
headline={t('aiTamper.noSignalsQuick')}
source={
onTryDeepScan ? (
<DeepScanHint onClick={onTryDeepScan}>
{t('aiTamper.tryDeepAnalysis')}
</DeepScanHint>
) : undefined
}
/>
)}
</>
);
};
const DeepScanHint = styled.button`
padding: 0;
border: none;
background: none;
font-family: ${typography.fontFamily.primary};
font-size: 12px;
font-weight: ${typography.fontWeight.semibold};
color: var(--accent-text);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
&:hover {
color: var(--accent-hover);
}
`;

View file

@ -0,0 +1,112 @@
import React, { useState } from 'react';
import {
CheckSquare, XCircle, AlertTriangle, HelpCircle, Search, Timer,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import type { ClaimsResult } from '../../../types/analysis-session';
import { HeadlineCard } from './HeadlineCard';
import { renderClaimsEditorial } from '../details/claims';
import { StatBlock, ChipsRow, Chip } from '../styles';
const credAccent = (s: number): string =>
s >= 60 ? '#22c55e' : s >= 40 ? '#eab308' : s >= 20 ? '#f97316' : '#ef4444';
const credIcon = (s: number): LucideIcon => {
if (s >= 60) return CheckSquare;
if (s >= 40) return HelpCircle;
if (s >= 20) return AlertTriangle;
return XCircle;
};
const buildTldr = (r: ClaimsResult, percent: number, isRo: boolean): string => {
const total = r.total_claims || r.claims_verified.length || 0;
if (total === 0) {
return isRo
? 'Nicio afirmație verificabilă în acest conținut.'
: 'No verifiable claims found in this content.';
}
const parts: string[] = [];
if (r.verified_true > 0) parts.push(`${r.verified_true} ${isRo ? 'adevărate' : 'true'}`);
if (r.verified_false > 0) parts.push(`${r.verified_false} ${isRo ? 'false' : 'false'}`);
if (r.unverified > 0) parts.push(`${r.unverified} ${isRo ? 'neverificate' : 'unverified'}`);
if (r.opinions > 0) parts.push(`${r.opinions} ${isRo ? 'opinii' : 'opinions'}`);
return isRo
? `${total} ${total === 1 ? 'afirmație' : 'afirmații'} analizate (${parts.join(', ')}). Scor de credibilitate: ${percent}%.`
: `${total} ${total === 1 ? 'claim' : 'claims'} analyzed (${parts.join(', ')}). Credibility score: ${percent}%.`;
};
interface Props {
result: ClaimsResult;
isRo: boolean;
}
export const ClaimsResults: React.FC<Props> = ({ result, isRo }) => {
const [expanded, setExpanded] = useState(false);
const rawCred = parseFloat(String(result.credibility_score)) || 0;
const credPercent = Math.round(rawCred > 1 ? rawCred : rawCred * 100);
const accent = credAccent(credPercent);
const Icon = credIcon(credPercent);
return (
<>
<HeadlineCard
accent={accent}
score={credPercent}
scoreLabel={isRo ? '% CREDIBIL' : '% CREDIBLE'}
eyebrow={isRo ? 'Verificarea afirmațiilor' : 'Claim verification'}
icon={Icon}
category={result.interpretation || (isRo ? 'Analiză afirmații' : 'Claim analysis')}
descriptor={
<span>
{result.total_claims} {isRo
? (result.total_claims === 1 ? 'afirmație' : 'afirmații')
: (result.total_claims === 1 ? 'claim' : 'claims')}
</span>
}
tldr={buildTldr(result, credPercent, isRo)}
/>
{result.total_claims > 0 && (
<StatBlock>
<ChipsRow>
{result.verified_true > 0 && (
<Chip variant="success">
{result.verified_true} {isRo ? 'adevărate' : 'verified true'}
</Chip>
)}
{result.verified_false > 0 && (
<Chip variant="critical">
{result.verified_false} {isRo ? 'false' : 'verified false'}
</Chip>
)}
{result.unverified > 0 && (
<Chip variant="neutral">
{result.unverified} {isRo ? 'neverificate' : 'unverified'}
</Chip>
)}
{result.opinions > 0 && (
<Chip variant="info">
{result.opinions} {isRo ? 'opinii' : 'opinions'}
</Chip>
)}
{result.web_searches_made > 0 && (
<Chip variant="violet">
<Search size={12} strokeWidth={2} />
{result.web_searches_made} {isRo ? 'căutări web' : 'web searches'}
</Chip>
)}
{result.total_duration_ms > 0 && (
<Chip variant="neutral">
<Timer size={12} strokeWidth={2} />
{(result.total_duration_ms / 1000).toFixed(1)}s
</Chip>
)}
</ChipsRow>
</StatBlock>
)}
{renderClaimsEditorial(result, isRo, expanded, () => setExpanded(o => !o))}
</>
);
};

View file

@ -0,0 +1,86 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import type { TechniqueDefinition } from '../../../services/technique-definitions.service';
import type { ComponentStatus } from '../types';
import { COMPONENT_LABEL_KEYS, COMPONENT_ORDER, STATUS_ICON } from '../utils';
import {
ComponentsGrid, ComponentCard, ComponentHeader, ComponentLeft, ComponentRight,
StatusIcon, ComponentName, DurationBadge, MiniSpinner, FailedBadge, ExpandArrow,
ComponentSummary, ComponentDetail,
} from '../styles';
import { renderTechniquesSummary, renderTechniquesDetail } from '../details/techniques';
import { renderAiSummary, renderAiDetail } from '../details/ai';
import { renderClaimsSummary, renderClaimsDetail } from '../details/claims';
import { renderDomainSummary, renderDomainDetail } from '../details/domain';
import { renderSourceSummary, renderSourceDetail } from '../details/source';
interface Props {
components: Record<string, ComponentStatus>;
fullResult: Record<string, any> | null;
expanded: Set<string>;
onToggle: (name: string) => void;
techniqueDefinitions: TechniqueDefinition[];
}
export const ComponentList: React.FC<Props> = ({ components, fullResult, expanded, onToggle, techniqueDefinitions }) => {
const { t } = useTranslation();
const active = COMPONENT_ORDER.filter(c => components[c] && components[c].status !== 'skipped');
if (active.length === 0) return null;
return (
<ComponentsGrid>
{active.map(name => {
const comp = components[name];
const isComplete = comp.status === 'completed';
const isFailed = comp.status === 'failed';
const isExpanded = expanded.has(name);
const result = fullResult?.[name] || (comp as any).result;
const hasResult = isComplete && !!result;
return (
<ComponentCard
key={name}
status={comp.status}
onClick={() => hasResult && onToggle(name)}
clickable={!!hasResult}
>
<ComponentHeader>
<ComponentLeft>
<StatusIcon status={comp.status}>{STATUS_ICON[comp.status]}</StatusIcon>
<ComponentName>{COMPONENT_LABEL_KEYS[name] ? t(COMPONENT_LABEL_KEYS[name]) : name}</ComponentName>
</ComponentLeft>
<ComponentRight>
{comp.duration_ms != null && (
<DurationBadge>{(comp.duration_ms / 1000).toFixed(1)}s</DurationBadge>
)}
{comp.status === 'running' && <MiniSpinner />}
{isFailed && <FailedBadge>{t('common.failed')}</FailedBadge>}
{hasResult && <ExpandArrow expanded={isExpanded} />}
</ComponentRight>
</ComponentHeader>
{isComplete && !isExpanded && (
<ComponentSummary>
{name === 'techniques' && renderTechniquesSummary(result, t)}
{name === 'ai_tampered' && renderAiSummary(result, t)}
{name === 'claims' && renderClaimsSummary(result, t)}
{name === 'domain' && renderDomainSummary(result)}
{name === 'source_assessment' && renderSourceSummary(result)}
</ComponentSummary>
)}
{isExpanded && hasResult && (
<ComponentDetail onClick={e => e.stopPropagation()}>
{name === 'techniques' && renderTechniquesDetail(result, techniqueDefinitions, t)}
{name === 'ai_tampered' && renderAiDetail(result, t)}
{name === 'claims' && renderClaimsDetail(result, t)}
{name === 'domain' && renderDomainDetail(result)}
{name === 'source_assessment' && renderSourceDetail(result)}
</ComponentDetail>
)}
</ComponentCard>
);
})}
</ComponentsGrid>
);
};

View file

@ -0,0 +1,80 @@
import React from 'react';
import { Info } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import styled from '@emotion/styled';
import {
FindingCardBox, FindingIconBox, FindingBody,
FindingEyebrow, FindingHeadline, FindingQuote, FindingSourceRow,
} from '../styles';
export type FindingAccent = 'critical' | 'warning' | 'info' | 'success' | 'neutral' | 'violet';
const ACCENT_HEX: Record<FindingAccent, string> = {
critical: '#ef4444',
warning: '#f97316',
info: '#3b82f6',
success: '#22c55e',
neutral: '#94a3b8',
violet: '#7fd0d4',
};
const HeadlineRow = styled.span`
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
`;
const InfoBubble = styled.span`
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent-subtle);
color: var(--accent-text);
cursor: help;
flex-shrink: 0;
transition: background 0.15s ease;
&:hover { background: var(--accent-subtle); }
`;
interface Props {
icon: LucideIcon;
accent?: FindingAccent;
dominant?: boolean;
iconLg?: boolean;
eyebrow?: React.ReactNode;
headline: React.ReactNode;
/** Optional explanation shown via a small ⓘ next to the headline (native browser tooltip). */
info?: string;
quote?: React.ReactNode;
source?: React.ReactNode;
}
export const FindingCard: React.FC<Props> = ({
icon: Icon, accent = 'critical', dominant, iconLg,
eyebrow, headline, info, quote, source,
}) => (
<FindingCardBox dominant={dominant}>
<FindingIconBox accent={ACCENT_HEX[accent]} lg={iconLg || dominant}>
<Icon size={iconLg || dominant ? 20 : 18} strokeWidth={2} />
</FindingIconBox>
<FindingBody>
{eyebrow && <FindingEyebrow>{eyebrow}</FindingEyebrow>}
<FindingHeadline dominant={dominant}>
<HeadlineRow>
{headline}
{info && (
<InfoBubble title={info} aria-label={info}>
<Info size={11} strokeWidth={2.5} />
</InfoBubble>
)}
</HeadlineRow>
</FindingHeadline>
{quote && <FindingQuote>{quote}</FindingQuote>}
{source && <FindingSourceRow>{source}</FindingSourceRow>}
</FindingBody>
</FindingCardBox>
);

View file

@ -0,0 +1,78 @@
import React, { useEffect, useRef, useState } from 'react';
import type { LucideIcon } from 'lucide-react';
import {
HeadlineCard as HeadlineCardBox,
GaugeCol, Gauge, GaugeTrack, GaugeFill, GaugeCenter, GaugeScore, GaugeOf,
HeadlineContent, HeadlineEyebrow, CategoryRow, CategoryIconBox, CategoryName, CategoryDesc,
TldrText,
} from '../styles';
interface Props {
accent: string;
score: number;
/** Optional secondary line for gauge center, e.g. "/ 100 RISC". */
scoreLabel?: string;
eyebrow: string;
icon: LucideIcon;
category: string;
/** Inline descriptor next to category (already composed with separators). */
descriptor?: React.ReactNode;
/** Optional TL;DR paragraph (Merriweather italic). */
tldr?: React.ReactNode;
}
/** Animated count-up — runs once on mount & on score change. */
function useCountUp(target: number, duration = 900, delay = 1100): number {
const [val, setVal] = useState(0);
const rafRef = useRef<number | null>(null);
useEffect(() => {
let start: number | null = null;
const tick = (ts: number) => {
if (start == null) start = ts;
const t = Math.min(1, (ts - start) / duration);
const eased = 1 - Math.pow(1 - t, 3);
setVal(Math.round(target * eased));
if (t < 1) rafRef.current = requestAnimationFrame(tick);
};
const timer = setTimeout(() => { rafRef.current = requestAnimationFrame(tick); }, delay);
return () => {
clearTimeout(timer);
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [target, duration, delay]);
return val;
}
export const HeadlineCard: React.FC<Props> = ({
accent, score, scoreLabel, eyebrow, icon: Icon, category, descriptor, tldr,
}) => {
const animated = useCountUp(score);
void animated; // gauge fills via CSS keyframe; static value used; reserved for future
return (
<HeadlineCardBox accent={accent}>
<GaugeCol>
<Gauge accent={accent}>
<svg viewBox="0 0 168 168">
<GaugeTrack cx={84} cy={84} r={80} />
<GaugeFill cx={84} cy={84} r={80} scorePercent={score} />
</svg>
<GaugeCenter>
<GaugeScore>{score}</GaugeScore>
{scoreLabel && <GaugeOf>{scoreLabel}</GaugeOf>}
</GaugeCenter>
</Gauge>
</GaugeCol>
<HeadlineContent>
<HeadlineEyebrow>{eyebrow}</HeadlineEyebrow>
<CategoryRow>
<CategoryIconBox accent={accent}>
<Icon size={18} strokeWidth={2.2} />
</CategoryIconBox>
<CategoryName>{category}</CategoryName>
{descriptor && <CategoryDesc>{descriptor}</CategoryDesc>}
</CategoryRow>
{tldr && <TldrText>{tldr}</TldrText>}
</HeadlineContent>
</HeadlineCardBox>
);
};

View file

@ -0,0 +1,139 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { validateText } from '../../../utils/text-validation';
import { validateUrl } from '../../../utils/url-validation';
import { validateFile } from '../../../utils/file-validation';
import { ConsentNotice, ConsentSlot, useMediaConsent } from '../../ConsentNotice';
import type { InputType } from '../types';
import { INPUT_TYPES } from '../utils';
import {
TypeSelector, TypeButton,
InputArea, TextInput, UrlInputWrapper, UrlInput, UrlHint,
FileDropZone, FileSelected, FileName, FileSize, RemoveFileBtn,
DropPlaceholder, DropLabel, DropHint, FileErrorMsg,
InputFooter, CharCount, AnalyzeBtn, Spinner,
} from '../styles';
interface Props {
inputType: InputType;
onTypeChange: (t: InputType) => void;
inputText: string;
onTextChange: (s: string) => void;
inputUrl: string;
onUrlChange: (s: string) => void;
selectedFile: File | null;
fileError: string | null;
fileInputRef: React.RefObject<HTMLInputElement>;
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
onRemoveFile: () => void;
isRunning: boolean;
statusMsg?: string;
onAnalyze: () => void;
}
export const InputForm: React.FC<Props> = ({
inputType, onTypeChange, inputText, onTextChange, inputUrl, onUrlChange,
selectedFile, fileError, fileInputRef, onFileSelect, onRemoveFile,
isRunning, statusMsg, onAnalyze,
}) => {
const { t } = useTranslation();
const textValidation = useMemo(() => validateText(inputText), [inputText]);
const urlValidation = useMemo(() => validateUrl(inputUrl), [inputUrl]);
const [mediaConsent, setMediaConsent] = useMediaConsent();
const isMediaInput = inputType !== 'text' && inputType !== 'url';
const canAnalyze = inputType === 'text'
? textValidation.valid
: inputType === 'url'
? urlValidation.valid
: selectedFile !== null && mediaConsent;
const charCountStyle =
inputType === 'text'
? { color: textValidation.level === 'error' ? '#ef4444' : textValidation.level === 'warning' ? '#eab308' : '#22c55e' }
: inputType === 'url' && urlValidation.error
? { color: '#ef4444' }
: inputType === 'url' && urlValidation.valid
? { color: '#22c55e' }
: undefined;
const charCountText = inputType === 'text'
? (textValidation.error || textValidation.warning || `${inputText.trim().length} characters`)
: inputType === 'url'
? (urlValidation.error || (inputUrl.trim() ? t('common.validUrl') : t('common.enterUrl')))
: selectedFile ? t('common.ready') : t('common.noFileSelected');
return (
<>
<TypeSelector>
{INPUT_TYPES.map(({ key, labelKey }) => (
<TypeButton key={key} active={inputType === key} onClick={() => onTypeChange(key)}>
{t(labelKey)}
</TypeButton>
))}
</TypeSelector>
<InputArea>
{inputType === 'text' ? (
<TextInput
placeholder={t('pipeline.textPlaceholder')}
value={inputText}
onChange={e => onTextChange(e.target.value)}
disabled={isRunning}
/>
) : inputType === 'url' ? (
<UrlInputWrapper>
<UrlInput
type="url"
placeholder={t('pipeline.urlPlaceholder')}
value={inputUrl}
onChange={e => onUrlChange(e.target.value)}
disabled={isRunning}
/>
<UrlHint>{t('pipeline.urlHint')}</UrlHint>
</UrlInputWrapper>
) : (
<>
<FileDropZone>
<input
ref={fileInputRef}
type="file"
accept={INPUT_TYPES.find(t => t.key === inputType)?.accept}
onChange={onFileSelect}
style={{ display: 'none' }}
/>
{selectedFile ? (
<FileSelected>
<FileName>{selectedFile.name}</FileName>
<FileSize>{(selectedFile.size / (1024 * 1024)).toFixed(1)} MB</FileSize>
<RemoveFileBtn onClick={onRemoveFile}>Remove</RemoveFileBtn>
</FileSelected>
) : (
<DropPlaceholder onClick={() => fileInputRef.current?.click()}>
<DropLabel>Click to select {inputType} file</DropLabel>
<DropHint>
{inputType === 'image' && t('pipeline.imageHint')}
{inputType === 'audio' && t('pipeline.audioHint')}
{inputType === 'video' && t('pipeline.videoHint')}
</DropHint>
</DropPlaceholder>
)}
{fileError && <FileErrorMsg>{fileError}</FileErrorMsg>}
</FileDropZone>
{isMediaInput && (
<ConsentSlot>
<ConsentNotice consented={mediaConsent} onChange={setMediaConsent} />
</ConsentSlot>
)}
</>
)}
<InputFooter>
<CharCount style={charCountStyle}>{charCountText}</CharCount>
<AnalyzeBtn onClick={onAnalyze} disabled={isRunning || !canAnalyze}>
{isRunning ? (<><Spinner />{statusMsg || t('common.analyzing')}</>) : t('pipeline.runFullAnalysis')}
</AnalyzeBtn>
</InputFooter>
</InputArea>
</>
);
};

View file

@ -0,0 +1,48 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import styled from '@emotion/styled';
import { spacing } from '../../../theme';
interface Props {
inputType: string;
text: string | null | undefined;
}
/** Split raw input into clean paragraphs. Single \n collapses to space; \n\n+ = new paragraph. */
function toParagraphs(raw: string): string[] {
return raw.split(/\n{2,}/).map(p => p.replace(/\s*\n\s*/g, ' ').trim()).filter(Boolean);
}
export const InputPreview: React.FC<Props> = ({ inputType, text }) => {
const { t } = useTranslation();
if (!text) return null;
const paragraphs = toParagraphs(text);
return (
<Block>
<Label>{t('history.inputLabel', { type: inputType })}</Label>
<Body>
{paragraphs.map((p, i) => <p key={i}>{p}</p>)}
</Body>
</Block>
);
};
const Block = styled.div`
display: flex; flex-direction: column; gap: 6px;
padding: 14px 18px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 14px;
margin-bottom: ${spacing.md}px;
`;
const Label = styled.div`
font-size: 10.5px; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase;
color: var(--fg-muted);
`;
const Body = styled.div`
font-size: 13px; color: var(--fg-secondary); line-height: 1.6;
word-break: break-word; overflow-wrap: break-word;
max-height: 240px; overflow-y: auto;
display: flex; flex-direction: column; gap: 10px;
& p { margin: 0; }
`;

View file

@ -0,0 +1,210 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { ComponentStatus } from '../types';
import { COMPONENT_LABEL_KEYS, COMPONENT_ORDER } from '../utils';
import {
ProgressSection, ProgressBar, ProgressFill, ProgressText,
LoadingShell, LoadingHeadline, LoadingPulse,
LoadingGrid, LoadingTile, LoadingTileTop, LoadingTileLeft,
LoadingTileIcon, LoadingTileName, LoadingTileDuration,
LoadingTileMicro, LoadingMiniSpinner,
LoadingFindings, LoadingFindingsLabel, LoadingFindingsRow, LoadingBullet,
} from '../styles';
interface Props {
progress: number;
components: Record<string, ComponentStatus>;
fullResult: Record<string, any> | null;
startedAt: number | null;
statusMsg?: string;
}
const ICON: Record<string, string> = {
pending: '○',
running: '◎',
completed: '✓',
failed: '✗',
skipped: '—',
};
/** Bilingual blurbs per component & state. */
const RUNNING_BLURB: Record<string, { ro: string; en: string }> = {
techniques: { ro: 'caut tehnici de manipulare…', en: 'scanning rhetoric techniques…' },
ai_tampered: { ro: 'evaluez semnalele AI…', en: 'analyzing AI signals…' },
claims: { ro: 'verific afirmațiile cu surse web…', en: 'verifying claims with web sources…' },
source_assessment: { ro: 'evaluez credibilitatea sursei…', en: 'checking source credibility…' },
};
const PENDING_BLURB: Record<string, { ro: string; en: string }> = {
techniques: { ro: 'așteaptă să caute tehnici', en: 'will scan techniques' },
ai_tampered: { ro: 'așteaptă să detecteze AI', en: 'will detect AI' },
claims: { ro: 'așteaptă verificarea web', en: 'will verify on the web' },
source_assessment: { ro: 'așteaptă evaluarea sursei', en: 'will assess source' },
};
function fmtMs(ms: number) {
if (ms < 1000) return `${ms}ms`;
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
return `${Math.floor(s / 60)}m ${s % 60}s`;
}
/** Build a component-specific micro-stat text from its result (when completed). */
function buildDoneMicro(name: string, result: any): string | null {
if (!result) return null;
switch (name) {
case 'techniques': {
const count = result.techniques_detected?.length || 0;
if (count === 0) return 'no techniques detected';
const score = typeof result.manipulation_score === 'number'
? result.manipulation_score
: parseFloat(result.manipulation_score) || 0;
return `${count} detected · score ${score.toFixed(0)}`;
}
case 'ai_tampered': {
const prob = typeof result.ai_probability === 'number'
? result.ai_probability
: parseFloat(result.ai_probability) || 0;
return `${prob.toFixed(0)}% AI probability`;
}
case 'claims': {
const total = result.total_claims || 0;
const f = result.verified_false || 0;
const t = result.verified_true || 0;
if (total === 0) return 'no claims found';
return `${total} claims · ${t} true · ${f} false`;
}
case 'source_assessment': {
const trust = result.trust_score;
if (trust == null) return 'no source data';
return `trust ${trust}%`;
}
default:
return null;
}
}
/** Build the running list of "Detectate până acum" bullets from progressive results. */
function buildLiveFindings(result: Record<string, any> | null, isRo: boolean): { color: string; text: string }[] {
if (!result) return [];
const out: { color: string; text: string }[] = [];
if (result.techniques?.techniques_detected?.length) {
const n = result.techniques.techniques_detected.length;
out.push({
color: '#ef4444',
text: isRo
? `${n} ${n === 1 ? 'tehnică de manipulare detectată' : 'tehnici de manipulare detectate'}`
: `${n} manipulation ${n === 1 ? 'technique' : 'techniques'} detected`,
});
}
if (result.ai_tampered?.ai_probability != null) {
const p = result.ai_tampered.ai_probability;
out.push({
color: p >= 60 ? '#f97316' : '#22c55e',
text: isRo ? `Probabilitate AI: ${p.toFixed(0)}%` : `AI probability: ${p.toFixed(0)}%`,
});
}
if (result.claims?.total_claims != null) {
const total = result.claims.total_claims;
const f = result.claims.verified_false || 0;
if (total > 0) {
out.push({
color: f > 0 ? '#ef4444' : '#3b82f6',
text: isRo
? `${total} ${total === 1 ? 'afirmație verificată' : 'afirmații verificate'}${f > 0 ? `, ${f} ${f === 1 ? 'falsă' : 'false'}` : ''}`
: `${total} claims verified${f > 0 ? `, ${f} false` : ''}`,
});
}
}
if (result.source_assessment?.trust_score != null) {
const t = result.source_assessment.trust_score;
out.push({
color: t >= 70 ? '#22c55e' : t >= 40 ? '#f97316' : '#ef4444',
text: isRo ? `Încredere sursă: ${t}%` : `Source trust: ${t}%`,
});
}
return out;
}
export const LoadingState: React.FC<Props> = ({ progress, components, fullResult, startedAt, statusMsg }) => {
const { t, i18n } = useTranslation();
const isRo = (i18n.language || '').toLowerCase().startsWith('ro');
const [now, setNow] = useState<number>(() => Date.now());
useEffect(() => {
if (!startedAt) return;
const id = setInterval(() => setNow(Date.now()), 500);
return () => clearInterval(id);
}, [startedAt]);
const elapsed = startedAt ? now - startedAt : 0;
const findings = buildLiveFindings(fullResult, isRo);
return (
<>
<ProgressSection>
<ProgressBar><ProgressFill width={progress} /></ProgressBar>
<ProgressText>
<span>{statusMsg || (isRo ? 'Analiză în curs…' : 'Analyzing…')}</span>
<span>{Math.round(progress)}% · {fmtMs(elapsed)}</span>
</ProgressText>
</ProgressSection>
<LoadingShell>
<LoadingHeadline>
<LoadingPulse />
{isRo ? 'DIDI analizează textul tău' : 'DIDI is analyzing your input'}
</LoadingHeadline>
<LoadingGrid>
{COMPONENT_ORDER.map(name => {
const comp = components[name];
const status = comp?.status || 'pending';
const result = fullResult?.[name];
const doneMicro = status === 'completed' ? buildDoneMicro(name, result) : null;
const micro = status === 'completed'
? (doneMicro || (isRo ? 'finalizat' : 'done'))
: status === 'running'
? (RUNNING_BLURB[name]?.[isRo ? 'ro' : 'en'] || (isRo ? 'în lucru…' : 'working…'))
: status === 'failed'
? (isRo ? 'eșuat' : 'failed')
: status === 'skipped'
? (isRo ? 'omis' : 'skipped')
: (PENDING_BLURB[name]?.[isRo ? 'ro' : 'en'] || (isRo ? 'în coadă' : 'queued'));
return (
<LoadingTile key={name} status={status}>
<LoadingTileTop>
<LoadingTileLeft>
<LoadingTileIcon status={status}>{ICON[status]}</LoadingTileIcon>
<LoadingTileName>{t(COMPONENT_LABEL_KEYS[name] || name)}</LoadingTileName>
</LoadingTileLeft>
{comp?.duration_ms != null && status === 'completed' && (
<LoadingTileDuration>{(comp.duration_ms / 1000).toFixed(1)}s</LoadingTileDuration>
)}
</LoadingTileTop>
<LoadingTileMicro status={status}>
{status === 'running' && <LoadingMiniSpinner />}
{micro}
</LoadingTileMicro>
</LoadingTile>
);
})}
</LoadingGrid>
{findings.length > 0 && (
<LoadingFindings>
<LoadingFindingsLabel>{isRo ? 'Detectate până acum' : 'Detected so far'}</LoadingFindingsLabel>
{findings.map((f, i) => (
<LoadingFindingsRow key={i}>
<LoadingBullet color={f.color} />
<span>{f.text}</span>
</LoadingFindingsRow>
))}
</LoadingFindings>
)}
</LoadingShell>
</>
);
};

View file

@ -0,0 +1,17 @@
import React from 'react';
import {
SectionDividerRow, SectionDividerLabel, SectionDividerLine, SectionDividerCount,
} from '../styles';
interface Props {
label: string;
count?: React.ReactNode;
}
export const SectionDivider: React.FC<Props> = ({ label, count }) => (
<SectionDividerRow>
<SectionDividerLabel>{label}</SectionDividerLabel>
<SectionDividerLine />
{count != null && <SectionDividerCount>{count}</SectionDividerCount>}
</SectionDividerRow>
);

View file

@ -0,0 +1,67 @@
import React from 'react';
import {
ShieldCheck, ShieldAlert, AlertTriangle, AlertOctagon,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { enumLabel } from '../../../utils/i18n-enums';
import type { SourceAssessmentResult } from '../../../types/analysis-session';
import { HeadlineCard } from './HeadlineCard';
import { renderSourceEditorial } from '../details/source';
const trustAccent = (s: number): string =>
s >= 70 ? '#22c55e' : s >= 40 ? '#eab308' : s >= 20 ? '#f97316' : '#ef4444';
const trustIcon = (s: number): LucideIcon => {
if (s >= 70) return ShieldCheck;
if (s >= 40) return ShieldAlert;
if (s >= 20) return AlertTriangle;
return AlertOctagon;
};
const buildTldr = (r: SourceAssessmentResult, isRo: boolean): string => {
const verdictTxt = enumLabel(r.verdict || '').replace(/_/g, ' ').toLowerCase();
const riskTxt = enumLabel(r.risk_level || '').toLowerCase();
if (isRo) {
return `Sursă ${verdictTxt} cu scor de încredere ${r.trust_score}/100${riskTxt ? `, risc ${riskTxt}` : ''}.`;
}
return `${verdictTxt.charAt(0).toUpperCase() + verdictTxt.slice(1)} source with trust score ${r.trust_score}/100${riskTxt ? ` · ${riskTxt} risk` : ''}.`;
};
interface Props {
result: SourceAssessmentResult;
isRo: boolean;
}
export const SourceResults: React.FC<Props> = ({ result: r, isRo }) => {
const accent = trustAccent(r.trust_score);
const Icon = trustIcon(r.trust_score);
return (
<>
<HeadlineCard
accent={accent}
score={r.trust_score}
scoreLabel={isRo ? '/ 100 ÎNCREDERE' : '/ 100 TRUST'}
eyebrow={isRo ? 'Evaluarea sursei' : 'Source assessment'}
icon={Icon}
category={enumLabel(r.verdict || '').replace(/_/g, ' ')}
descriptor={
<>
{r.risk_level && (
<span>{isRo ? 'Risc' : 'Risk'} {enumLabel(r.risk_level).toLowerCase()}</span>
)}
{r.publication?.name && (
<>
<span className="sep">·</span>
<span>{r.publication.name}</span>
</>
)}
</>
}
tldr={buildTldr(r, isRo)}
/>
{renderSourceEditorial(r, isRo)}
</>
);
};

View file

@ -0,0 +1,125 @@
import React, { useState } from 'react';
import { AlertOctagon, AlertTriangle, Info, ShieldCheck } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import type { TechniquesResult } from '../../../types/analysis-session';
import type { TechniqueDefinition } from '../../../services/technique-definitions.service';
import { HeadlineCard } from './HeadlineCard';
import { FindingCard } from './FindingCard';
import { renderTechniquesEditorial } from '../details/techniques';
import { StatBlock, ChipsRow, Chip } from '../styles';
const scoreAccent = (s: number): string =>
s >= 70 ? '#ef4444' : s >= 40 ? '#f97316' : s >= 20 ? '#eab308' : '#22c55e';
const scoreCategory = (s: number, isRo: boolean): string => {
if (s >= 70) return isRo ? 'Critic' : 'Critical';
if (s >= 40) return isRo ? 'Ridicat' : 'High';
if (s >= 20) return isRo ? 'Mediu' : 'Medium';
return isRo ? 'Scăzut' : 'Low';
};
const scoreIcon = (s: number): LucideIcon => {
if (s >= 70) return AlertOctagon;
if (s >= 40) return AlertTriangle;
if (s >= 20) return Info;
return ShieldCheck;
};
const buildTldr = (r: TechniquesResult, isRo: boolean): string => {
const techCount = r.techniques_detected.length;
const dimCount = r.dimensions_affected.length;
const dims = r.dimensions_affected.join(' · ');
if (techCount === 0) {
return isRo
? 'Nicio tehnică de manipulare detectată în acest conținut.'
: 'No manipulation techniques detected in this content.';
}
return isRo
? `${techCount} ${techCount === 1 ? 'tehnică' : 'tehnici'} de manipulare detectate în ${dimCount} ${dimCount === 1 ? 'dimensiune' : 'dimensiuni'}${dims ? ` (${dims})` : ''}.`
: `${techCount} manipulation ${techCount === 1 ? 'technique' : 'techniques'} detected across ${dimCount} ${dimCount === 1 ? 'dimension' : 'dimensions'}${dims ? ` (${dims})` : ''}.`;
};
interface Props {
result: TechniquesResult;
isRo: boolean;
techDefs: TechniqueDefinition[];
}
/** Editorial result section for Manipulation Techniques — shared between live standalone and history detail. */
export const TechniquesResults: React.FC<Props> = ({ result, isRo, techDefs }) => {
const [expanded, setExpanded] = useState(false);
const score = result.manipulation_score ?? 0;
const accent = scoreAccent(score);
const Icon = scoreIcon(score);
const techCount = result.techniques_detected.length;
const dimCount = result.dimensions_affected.length;
const warningFlags = result.coupling_context?.for_claims?.warning_flags ?? [];
return (
<>
<HeadlineCard
accent={accent}
score={score}
scoreLabel={isRo ? '/ 100 SCOR' : '/ 100 SCORE'}
eyebrow={isRo ? 'Tehnici de manipulare' : 'Manipulation techniques'}
icon={Icon}
category={scoreCategory(score, isRo)}
descriptor={
<>
<span>{techCount} {isRo ? (techCount === 1 ? 'tehnică' : 'tehnici') : (techCount === 1 ? 'technique' : 'techniques')}</span>
{dimCount > 0 && (
<>
<span className="sep">·</span>
<span>{dimCount} {isRo ? (dimCount === 1 ? 'dimensiune' : 'dimensiuni') : (dimCount === 1 ? 'dimension' : 'dimensions')}</span>
</>
)}
</>
}
tldr={buildTldr(result, isRo)}
/>
{(score > 0 || dimCount > 0) && (
<StatBlock>
<ChipsRow>
{result.dimensions_affected.map(dim => (
<Chip key={dim} variant="info">{dim}</Chip>
))}
{techCount > 0 && (
<Chip variant="violet">
{techCount} {isRo ? 'tehnici detectate' : 'techniques detected'}
</Chip>
)}
</ChipsRow>
</StatBlock>
)}
{techCount > 0 && renderTechniquesEditorial(
result,
techDefs,
isRo,
expanded,
() => setExpanded(o => !o),
)}
{techCount === 0 && (
<FindingCard
icon={ShieldCheck}
accent="success"
eyebrow={isRo ? 'Conținut curat' : 'Clean content'}
headline={isRo ? 'Nicio tehnică de manipulare detectată' : 'No manipulation techniques detected'}
/>
)}
{warningFlags.length > 0 && (
<FindingCard
icon={AlertTriangle}
accent="warning"
eyebrow={isRo ? 'Semnale adiționale' : 'Additional signals'}
headline={isRo ? 'Indicatori de manipulare detectați la screening' : 'Manipulation signals detected at screening'}
quote={warningFlags.map(f => f.replace(/_/g, ' ')).join(' · ')}
/>
)}
</>
);
};

View file

@ -0,0 +1,453 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
AlertOctagon, AlertTriangle, Info, ShieldCheck,
OctagonX, AlertCircle, BarChart3, Layers, ExternalLink,
Zap, UserX, Quote, Users, ShieldQuestion,
Cpu, ShieldAlert, XCircle, CheckCircle2, HelpCircle,
TrendingUp, Timer, Plus, ChevronDown, Tag, BarChart2,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import type {
VerdictResult, VerdictSummary, KeyFinding,
} from '../../../types/analysis-session';
import type { TechniqueDefinition } from '../../../services/technique-definitions.service';
import { localizedExplanation } from '../../../utils/i18n-fields';
import { enumLabel } from '../../../utils/i18n-enums';
import type { ComponentStatus } from '../types';
import { RISK_COLORS, getViralityColor, safeHostname } from '../utils';
import {
VerdictSection,
ActionCallout, ActionIconBox, ActionBody, ActionEyebrow, ActionHeadline, ActionText,
CollapseBtn, CollapseBtnLeft, CollapseBtnChevron,
StatBlock, ScoresBlock, ScoreLine, ScoreLineName, ScoreLineBar, ScoreLineFill, ScoreLineNum, ScoreLineSkipped,
ChipsRow, Chip,
LegacyExplanation,
} from '../styles';
import { HeadlineCard } from './HeadlineCard';
import { FindingCard, type FindingAccent } from './FindingCard';
import { SectionDivider } from './SectionDivider';
import { renderTechniquesEditorial } from '../details/techniques';
import { renderAiEditorial } from '../details/ai';
import { renderClaimsEditorial } from '../details/claims';
import { renderSourceEditorial } from '../details/source';
interface Props {
verdict: VerdictResult;
totalDuration: number | null;
components: Record<string, ComponentStatus>;
fullResult: Record<string, any> | null;
techniqueDefinitions: TechniqueDefinition[];
}
const SEVERITY_RANK: Record<string, number> = { critical: 0, warning: 1, info: 2 };
/** Backend occasionally leaks debug strings (e.g. `ai_probability=85/100, verdict="LIKELY_AI"`,
* `Claims: [SKIPPED]`, `data_quality=full`) into evidence_ref.quote. Hide those keep real quotes. */
function cleanQuote(q: string | null | undefined): string | undefined {
if (!q) return undefined;
if (/\[SKIPPED\]/i.test(q)) return undefined;
if (/^\s*\w+\s*=\s*[\d"']/.test(q)) return undefined;
if (/data_quality\s*=/i.test(q)) return undefined;
return q;
}
/** Map finding severity → icon + accent. */
function findingIcon(f: KeyFinding): { icon: LucideIcon; accent: FindingAccent } {
const sev = f.severity;
const type = f.type;
// Type-based mapping (richer than just severity)
const TYPE_ICONS: Record<string, LucideIcon> = {
false_claim: BarChart3,
fabricated_quote: Quote,
manipulation: AlertCircle,
urgency: Zap,
imposter_content: UserX,
conspiracy: ShieldQuestion,
demographic: Users,
};
const accent: FindingAccent = sev === 'critical' ? 'critical' : sev === 'warning' ? 'warning' : 'info';
return { icon: TYPE_ICONS[type as string] || AlertCircle, accent };
}
/** Action callout eyebrow text by severity + locale. */
function actionEyebrow(severity: string, isRo: boolean): string {
if (severity === 'critical') return isRo ? 'NU DISTRIBUI' : 'DO NOT SHARE';
if (severity === 'warning') return isRo ? 'CITEȘTE CRITIC' : 'READ CRITICALLY';
return isRo ? 'DE REȚINUT' : 'NOTE';
}
/** Category icon by risk_category_color. */
function categoryIcon(color: string): LucideIcon {
if (color === 'red' || color === 'darkred') return AlertOctagon;
if (color === 'orange') return AlertTriangle;
if (color === 'yellow') return Info;
if (color === 'green' || color === 'lightgreen') return ShieldCheck;
return Info;
}
/** Animated count-up — re-runs when target changes. */
function useCountUp(target: number, duration = 900, delay = 200): number {
const [val, setVal] = useState(0);
const rafRef = useRef<number | null>(null);
useEffect(() => {
let start: number | null = null;
const begin = (ts: number) => { start = ts; tick(ts); };
const tick = (ts: number) => {
if (start == null) start = ts;
const t = Math.min(1, (ts - start) / duration);
const eased = 1 - Math.pow(1 - t, 3);
setVal(Math.round(target * eased));
if (t < 1) rafRef.current = requestAnimationFrame(tick);
};
const timer = setTimeout(() => { rafRef.current = requestAnimationFrame(begin); }, delay);
return () => {
clearTimeout(timer);
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [target, duration, delay]);
return val;
}
export const Verdict: React.FC<Props> = ({
verdict, totalDuration, components, fullResult, techniqueDefinitions,
}) => {
const { t, i18n } = useTranslation();
const isRo = (i18n.language || '').toLowerCase().startsWith('ro');
const [secondaryOpen, setSecondaryOpen] = useState(false);
const [detailsOpen, setDetailsOpen] = useState(false);
const [techExpand, setTechExpand] = useState(false);
const [claimsExpand, setClaimsExpand] = useState(false);
const summary = (verdict.context_summary as { verdict_summary?: VerdictSummary } | undefined)?.verdict_summary;
const accent = RISK_COLORS[verdict.risk_category_color] || '#94a3b8';
const animatedScore = useCountUp(verdict.risk_score, 900, 1100);
void animatedScore; // gauge fills via CSS keyframe; we use it visually for text
const sortedFindings = useMemo<KeyFinding[]>(() => {
if (!summary?.key_findings) return [];
return [...summary.key_findings].sort(
(a, b) => (SEVERITY_RANK[a.severity] ?? 3) - (SEVERITY_RANK[b.severity] ?? 3),
);
}, [summary]);
const dominant = sortedFindings[0];
const rest = sortedFindings.slice(1);
const CatIcon = categoryIcon(verdict.risk_category_color);
// Tier 4 chips data
const techniquesCount = verdict.context_summary?.techniques_detected ?? 0;
const falseClaims = verdict.context_summary?.claims_false ?? 0;
const elapsedSec = totalDuration ? `${(totalDuration / 1000).toFixed(1)}s` : null;
const viralityScore = verdict.virality_score;
const viralityLevel = verdict.virality_level;
const scoreEntries: { name: string; value: number | null }[] = [
{ name: isRo ? 'Manipulare' : 'Manipulation', value: verdict.score_manipulation ?? null },
{ name: 'Claims', value: verdict.score_claims ?? null },
{ name: isRo ? 'AI generation' : 'AI generation', value: verdict.score_ai ?? null },
{ name: isRo ? 'Sursă' : 'Source', value: verdict.score_source ?? null },
];
const hasComponentData = !!(fullResult?.techniques || fullResult?.ai_tampered ||
fullResult?.claims || fullResult?.source_assessment);
return (
<VerdictSection>
{summary ? (
<>
{/* ─── TIER 1 — HEADLINE cu gauge ─── */}
<HeadlineCard
accent={accent}
score={verdict.risk_score}
scoreLabel={`/ 100 ${isRo ? 'RISC' : 'RISK'}`}
eyebrow={isRo ? 'Risc analizat' : 'Risk analyzed'}
icon={CatIcon}
category={enumLabel(verdict.risk_category || '').replace(/_/g, ' ')}
descriptor={
<>
{verdict.risk_level && (
<>
<span>{enumLabel(verdict.risk_level)}</span>
<span className="sep">·</span>
</>
)}
{verdict.confidence != null && (
<span>{isRo ? 'Certitudine' : 'Confidence'} {verdict.confidence}%</span>
)}
</>
}
tldr={isRo ? summary.tl_dr_ro : summary.tl_dr_en}
/>
{/* ─── TIER 2 — ACTION CALLOUT ─── */}
{(summary.what_to_do_ro || summary.what_to_do_en) && (
<ActionCallout accent={accent}>
<ActionIconBox accent={accent}>
<OctagonX size={26} strokeWidth={2} />
</ActionIconBox>
<ActionBody>
<ActionEyebrow accent={accent}>
{actionEyebrow(dominant?.severity || 'info', isRo)}
</ActionEyebrow>
<ActionHeadline>
{isRo
? (dominant?.severity === 'critical' ? 'Nu distribui acest articol' : 'Citește cu atenție')
: (dominant?.severity === 'critical' ? 'Do not share this article' : 'Read carefully')}
</ActionHeadline>
<ActionText>{isRo ? summary.what_to_do_ro : summary.what_to_do_en}</ActionText>
</ActionBody>
</ActionCallout>
)}
{/* ─── TIER 3 — DOMINANT FINDING ─── */}
{dominant && (
<>
<SectionDivider label={isRo ? 'Cea mai gravă problemă' : 'Top issue'} />
{(() => {
const { icon, accent: a } = findingIcon(dominant);
return (
<FindingCard
dominant
icon={icon}
accent={a}
headline={isRo ? dominant.ro : dominant.en}
quote={cleanQuote(dominant.evidence_ref?.quote)}
source={dominant.evidence_ref?.source_url ? (
<>
<ExternalLink size={13} strokeWidth={2} />
<span>{isRo ? 'Verificat de' : 'Verified by'}</span>
<a
href={dominant.evidence_ref.source_url}
target="_blank" rel="noopener noreferrer"
style={{ color: 'var(--accent-text)', textDecoration: 'none', fontWeight: 500 }}
>
{safeHostname(dominant.evidence_ref.source_url)}
</a>
</>
) : undefined}
/>
);
})()}
{/* secondary findings collapse */}
{rest.length > 0 && (
<>
<CollapseBtn onClick={() => setSecondaryOpen(o => !o)}>
<CollapseBtnLeft>
<Layers size={16} strokeWidth={2} />
<span>
{secondaryOpen
? (isRo ? 'Ascunde celelalte semne' : 'Hide other signals')
: (isRo
? `Vezi ${rest.length} ${rest.length === 1 ? 'alt semn de manipulare' : 'alte semne de manipulare'}`
: `Show ${rest.length} more ${rest.length === 1 ? 'manipulation signal' : 'manipulation signals'}`)}
</span>
</CollapseBtnLeft>
<CollapseBtnChevron expanded={secondaryOpen}>
<ChevronDown size={14} strokeWidth={2.5} />
</CollapseBtnChevron>
</CollapseBtn>
{secondaryOpen && (
<>
<SectionDivider
label={isRo ? 'Alte semne de manipulare' : 'Other manipulation signals'}
count={rest.length}
/>
{rest.map((f, idx) => {
const { icon, accent: a } = findingIcon(f);
return (
<FindingCard
key={idx}
icon={icon}
accent={a}
headline={isRo ? f.ro : f.en}
quote={cleanQuote(f.evidence_ref?.quote)}
source={f.evidence_ref?.source_url ? (
<>
<ExternalLink size={13} strokeWidth={2} />
<a
href={f.evidence_ref.source_url}
target="_blank" rel="noopener noreferrer"
style={{ color: 'var(--accent-text)', textDecoration: 'none', fontWeight: 500 }}
>
{safeHostname(f.evidence_ref.source_url)}
</a>
</>
) : (
<>
<Tag size={13} strokeWidth={2} />
<span>{f.type.replace(/_/g, ' ')}</span>
</>
)}
/>
);
})}
</>
)}
</>
)}
</>
)}
{/* ─── TIER 4 — DETALII TEHNICE (collapse) ─── */}
<CollapseBtn onClick={() => setDetailsOpen(o => !o)}>
<CollapseBtnLeft>
<BarChart3 size={16} strokeWidth={2} />
<span>{isRo ? 'Detalii tehnice' : 'Technical details'}</span>
</CollapseBtnLeft>
<CollapseBtnChevron expanded={detailsOpen}>
<ChevronDown size={14} strokeWidth={2.5} />
</CollapseBtnChevron>
</CollapseBtn>
{detailsOpen && (
<>
{/* Stat block — score bars + chips */}
<StatBlock>
<ScoresBlock>
{scoreEntries.map(({ name, value }, i) => (
<ScoreLine key={name}>
<ScoreLineName>{name}</ScoreLineName>
<ScoreLineBar>
{value != null && (
<ScoreLineFill
width={value}
color={value >= 70 ? '#ef4444' : value >= 40 ? '#f97316' : '#22c55e'}
delay={200 + i * 80}
/>
)}
</ScoreLineBar>
{value != null
? <ScoreLineNum>{value.toFixed(0)}</ScoreLineNum>
: <ScoreLineSkipped></ScoreLineSkipped>}
</ScoreLine>
))}
</ScoresBlock>
<ChipsRow>
{viralityScore != null && (
<Chip variant={viralityScore >= 50 ? 'critical' : viralityScore >= 25 ? 'warning' : 'success'}>
<TrendingUp size={12} strokeWidth={2} />
Virality {viralityScore}{viralityLevel ? ` ${enumLabel(viralityLevel)}` : ''}
</Chip>
)}
{techniquesCount > 0 && (
<Chip variant="violet">
<Layers size={12} strokeWidth={2} />
{techniquesCount} {isRo ? 'tehnici detectate' : 'techniques detected'}
</Chip>
)}
{falseClaims > 0 && (
<Chip variant="critical">
<XCircle size={12} strokeWidth={2} />
{falseClaims} {isRo ? 'afirmații false' : 'false claims'}
</Chip>
)}
{elapsedSec && (
<Chip variant="neutral">
<Timer size={12} strokeWidth={2} />
{elapsedSec} {isRo ? 'analiză' : 'analysis'}
</Chip>
)}
</ChipsRow>
</StatBlock>
{/* AI Detection */}
{fullResult?.ai_tampered && renderAiEditorial(fullResult.ai_tampered, isRo)}
{/* Source Assessment */}
{fullResult?.source_assessment && renderSourceEditorial(fullResult.source_assessment, isRo)}
{/* Techniques */}
{fullResult?.techniques && renderTechniquesEditorial(fullResult.techniques, techniqueDefinitions, isRo, techExpand, () => setTechExpand(o => !o))}
{/* Claims */}
{fullResult?.claims && renderClaimsEditorial(fullResult.claims, isRo, claimsExpand, () => setClaimsExpand(o => !o))}
{/* If we have components but no detail data */}
{!hasComponentData && (
<FindingCard
icon={Info}
accent="neutral"
headline={isRo ? 'Detalii pe componente indisponibile' : 'No component data available'}
/>
)}
</>
)}
</>
) : (
// ═══ LEGACY FALLBACK ═══
<>
<HeadlineCard
accent={accent}
score={verdict.risk_score}
scoreLabel={`/ 100 ${isRo ? 'RISC' : 'RISK'}`}
eyebrow={isRo ? 'Risc analizat' : 'Risk analyzed'}
icon={CatIcon}
category={enumLabel(verdict.risk_category || '').replace(/_/g, ' ')}
descriptor={verdict.confidence != null
? <span>{isRo ? 'Certitudine' : 'Confidence'} {verdict.confidence}%</span>
: undefined}
/>
{(verdict.explanation_en || verdict.explanation_ro) && (
<LegacyExplanation>{localizedExplanation(verdict)}</LegacyExplanation>
)}
<CollapseBtn onClick={() => setDetailsOpen(o => !o)}>
<CollapseBtnLeft>
<BarChart3 size={16} strokeWidth={2} />
<span>{isRo ? 'Detalii tehnice' : 'Technical details'}</span>
</CollapseBtnLeft>
<CollapseBtnChevron expanded={detailsOpen}>
<ChevronDown size={14} strokeWidth={2.5} />
</CollapseBtnChevron>
</CollapseBtn>
{detailsOpen && (
<>
<StatBlock>
<ScoresBlock>
{scoreEntries.map(({ name, value }, i) => (
<ScoreLine key={name}>
<ScoreLineName>{name}</ScoreLineName>
<ScoreLineBar>
{value != null && (
<ScoreLineFill
width={value}
color={value >= 70 ? '#ef4444' : value >= 40 ? '#f97316' : '#22c55e'}
delay={200 + i * 80}
/>
)}
</ScoreLineBar>
{value != null
? <ScoreLineNum>{value.toFixed(0)}</ScoreLineNum>
: <ScoreLineSkipped></ScoreLineSkipped>}
</ScoreLine>
))}
</ScoresBlock>
<ChipsRow>
{viralityScore != null && (
<Chip variant={viralityScore >= 50 ? 'critical' : 'warning'}>
<TrendingUp size={12} strokeWidth={2} />
Virality {viralityScore}
</Chip>
)}
{elapsedSec && (
<Chip variant="neutral">
<Timer size={12} strokeWidth={2} />
{elapsedSec}
</Chip>
)}
</ChipsRow>
</StatBlock>
</>
)}
</>
)}
</VerdictSection>
);
};