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:
commit
cec967f953
321 changed files with 80506 additions and 0 deletions
86
web/src/components/PipelineAnalysis/DidYouKnowCard.tsx
Normal file
86
web/src/components/PipelineAnalysis/DidYouKnowCard.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { spacing } from '../../theme';
|
||||
import { localized } from '../../utils/i18n-fields';
|
||||
import type { TechniqueDefinition } from '../../services/technique-definitions.service';
|
||||
|
||||
const CYCLE_MS = 7000;
|
||||
const FADE_MS = 400;
|
||||
|
||||
interface Props {
|
||||
definitions: TechniqueDefinition[];
|
||||
}
|
||||
|
||||
const formatName = (name: string) => {
|
||||
const part = name.includes('.') ? name.split('.').pop()! : name;
|
||||
return part.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
};
|
||||
|
||||
export const DidYouKnowCard: React.FC<Props> = ({ definitions }) => {
|
||||
const { t } = useTranslation();
|
||||
const [index, setIndex] = useState(() => Math.floor(Math.random() * definitions.length));
|
||||
const [fading, setFading] = useState(false);
|
||||
|
||||
const cycle = useCallback(() => {
|
||||
setFading(true);
|
||||
setTimeout(() => {
|
||||
setIndex(prev => {
|
||||
let next;
|
||||
do { next = Math.floor(Math.random() * definitions.length); } while (next === prev && definitions.length > 1);
|
||||
return next;
|
||||
});
|
||||
setFading(false);
|
||||
}, FADE_MS);
|
||||
}, [definitions.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(cycle, CYCLE_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [cycle]);
|
||||
|
||||
if (!definitions.length) return null;
|
||||
const def = definitions[index];
|
||||
if (!def) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Label>{t('pipeline.didYouKnow')}</Label>
|
||||
<Content fading={fading}>
|
||||
<TechName>{formatName(localized(def, 'technique_name'))}</TechName>
|
||||
<Desc>{localized(def, 'description')}</Desc>
|
||||
<Meta>{localized(def, 'dimension_name')} — {localized(def, 'subdimension_name', 'subdimension')}</Meta>
|
||||
</Content>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const Card = styled.div`
|
||||
padding: 16px 20px; margin-bottom: ${spacing.md}px;
|
||||
background: var(--accent-subtle);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 10px;
|
||||
border-left: 3px solid var(--accent);
|
||||
`;
|
||||
|
||||
const Label = styled.div`
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;
|
||||
color: var(--accent-text); margin-bottom: 10px;
|
||||
`;
|
||||
|
||||
const Content = styled.div<{ fading: boolean }>`
|
||||
opacity: ${p => p.fading ? 0 : 1};
|
||||
transition: opacity ${FADE_MS}ms ease;
|
||||
`;
|
||||
|
||||
const TechName = styled.div`
|
||||
font-size: 14px; font-weight: 600; color: var(--fg-primary); margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const Desc = styled.div`
|
||||
font-size: 13px; line-height: 1.5; color: var(--fg-secondary);
|
||||
`;
|
||||
|
||||
const Meta = styled.div`
|
||||
font-size: 11px; color: var(--fg-subtle); margin-top: 8px;
|
||||
`;
|
||||
287
web/src/components/PipelineAnalysis/PipelineAnalysis.tsx
Normal file
287
web/src/components/PipelineAnalysis/PipelineAnalysis.tsx
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAsyncAnalysis } from '../../hooks/useAsyncAnalysis';
|
||||
import { validateFile } from '../../utils/file-validation';
|
||||
import { TechniqueDefinitionsService, type TechniqueDefinition } from '../../services/technique-definitions.service';
|
||||
import type { AnalysisSession, VerdictResult } from '../../types/analysis-session';
|
||||
import type { InputType, ComponentStatus } from './types';
|
||||
import { COMPONENT_ORDER } from './utils';
|
||||
import {
|
||||
Container, Header, Title, Subtitle,
|
||||
ErrorBox, ErrorIcon,
|
||||
SkipBox, SkipText, SkipAction,
|
||||
WarningBox,
|
||||
} from './styles';
|
||||
import { InputForm } from './sections/InputForm';
|
||||
import { InputPreview } from './sections/InputPreview';
|
||||
import { LoadingState } from './sections/LoadingState';
|
||||
import { Verdict } from './sections/Verdict';
|
||||
import { DownloadPdfButton } from '../Reports/DownloadPdfButton';
|
||||
import { ShareReportButton } from '../Reports/ShareReportButton';
|
||||
|
||||
// Re-exports — preserve backward-compat for ../pages/History which imports
|
||||
// the detail renderers from this file path.
|
||||
export { renderTechniquesDetail } from './details/techniques';
|
||||
export { renderAiDetail } from './details/ai';
|
||||
export { renderClaimsDetail } from './details/claims';
|
||||
export { renderDomainDetail } from './details/domain';
|
||||
export { renderSourceDetail } from './details/source';
|
||||
|
||||
export const PipelineAnalysis: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const async = useAsyncAnalysis('pipeline');
|
||||
|
||||
const [inputType, setInputType] = useState<InputType>('text');
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [inputUrl, setInputUrl] = useState('');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [fileError, setFileError] = useState<string | null>(null);
|
||||
|
||||
const [components, setComponents] = useState<Record<string, ComponentStatus>>({});
|
||||
const [verdict, setVerdict] = useState<VerdictResult | null>(null);
|
||||
const [fullResult, setFullResult] = useState<Record<string, any> | null>(null);
|
||||
const [analysisSession, setAnalysisSession] = useState<AnalysisSession | null>(null);
|
||||
const [totalDuration, setTotalDuration] = useState<number | null>(null);
|
||||
const [expandedComponents, setExpandedComponents] = useState<Set<string>>(new Set());
|
||||
const [techniqueDefinitions, setTechniqueDefinitions] = useState<TechniqueDefinition[]>([]);
|
||||
const [startedAt, setStartedAt] = useState<number | null>(null);
|
||||
// Snapshot of what was actually submitted (separate from current textarea).
|
||||
const [analyzedInput, setAnalyzedInput] = useState<{ type: InputType; text: string } | null>(null);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Load technique definitions once (cached in service)
|
||||
useEffect(() => {
|
||||
TechniqueDefinitionsService.getDefinitions()
|
||||
.then(setTechniqueDefinitions)
|
||||
.catch(err => console.warn('Failed to load technique definitions:', err));
|
||||
}, []);
|
||||
|
||||
// Track when analysis starts so LoadingState can show elapsed time.
|
||||
useEffect(() => {
|
||||
if (async.isAnalyzing && !startedAt) setStartedAt(Date.now());
|
||||
if (!async.isAnalyzing && startedAt && async.result) {
|
||||
// keep startedAt around — totalDuration replaces it post-finish
|
||||
}
|
||||
}, [async.isAnalyzing, async.result, startedAt]);
|
||||
|
||||
// Final result arrived (AnalysisSession)
|
||||
useEffect(() => {
|
||||
if (!async.result) return;
|
||||
const data: AnalysisSession = async.result;
|
||||
setAnalysisSession(data);
|
||||
if (data.verdict) setVerdict(data.verdict);
|
||||
|
||||
const compData: Record<string, any> = {};
|
||||
if (data.techniques) compData.techniques = data.techniques;
|
||||
if (data.ai_tampered) compData.ai_tampered = data.ai_tampered;
|
||||
if (data.claims) compData.claims = data.claims;
|
||||
if (data.domain) compData.domain = data.domain;
|
||||
if (data.source_assessment) compData.source_assessment = data.source_assessment;
|
||||
if (Object.keys(compData).length > 0) setFullResult(compData);
|
||||
if (data.total_duration_ms) setTotalDuration(data.total_duration_ms);
|
||||
|
||||
if (data.components_run) {
|
||||
const updated: Record<string, ComponentStatus> = {};
|
||||
for (const comp of data.components_run) {
|
||||
const key = comp === 'domain' && data.source_assessment ? 'source_assessment' : comp;
|
||||
updated[key] = { status: 'completed' };
|
||||
}
|
||||
for (const comp of data.components_skipped || []) {
|
||||
const key = comp === 'domain' && data.source_assessment ? 'source_assessment' : comp;
|
||||
updated[key] = { status: 'skipped' };
|
||||
}
|
||||
setComponents(updated);
|
||||
}
|
||||
}, [async.result]);
|
||||
|
||||
// Progressive results from polling — update component statuses + partial results
|
||||
useEffect(() => {
|
||||
if (!async.session) return;
|
||||
const session: AnalysisSession = async.session;
|
||||
|
||||
setComponents(prev => {
|
||||
const updated: Record<string, ComponentStatus> = { ...prev };
|
||||
const mapComp = (c: string) => c === 'domain' && session.source_assessment ? 'source_assessment' : c;
|
||||
|
||||
for (const comp of session.components_run || []) {
|
||||
const key = mapComp(comp);
|
||||
updated[key] = { ...(updated[key] || {}), status: 'completed' };
|
||||
}
|
||||
|
||||
// Mark next-in-line components as running
|
||||
if (session.status === 'running' && session._queue) {
|
||||
const total = session._queue.total_components;
|
||||
const done = (session.components_run || []).length;
|
||||
const remaining = total - done;
|
||||
let runningCount = 0;
|
||||
const mappedRun = (session.components_run || []).map(mapComp);
|
||||
for (const comp of COMPONENT_ORDER) {
|
||||
if (mappedRun.includes(comp)) continue;
|
||||
if (runningCount >= remaining) break;
|
||||
if (!updated[comp] || updated[comp].status !== 'completed') {
|
||||
updated[comp] = { ...(updated[comp] || {}), status: 'running' };
|
||||
}
|
||||
runningCount++;
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
const compData: Record<string, any> = {};
|
||||
if (session.techniques) compData.techniques = session.techniques;
|
||||
if (session.ai_tampered) compData.ai_tampered = session.ai_tampered;
|
||||
if (session.claims) compData.claims = session.claims;
|
||||
if (session.domain) compData.domain = session.domain;
|
||||
if (session.source_assessment) compData.source_assessment = session.source_assessment;
|
||||
if (Object.keys(compData).length > 0) {
|
||||
setFullResult(prev => ({ ...prev, ...compData }));
|
||||
}
|
||||
}, [async.session]);
|
||||
|
||||
const resetResults = () => {
|
||||
setComponents({});
|
||||
setVerdict(null);
|
||||
setFullResult(null);
|
||||
setAnalysisSession(null);
|
||||
setTotalDuration(null);
|
||||
setExpandedComponents(new Set());
|
||||
setStartedAt(null);
|
||||
setAnalyzedInput(null);
|
||||
async.reset();
|
||||
};
|
||||
|
||||
const handleTypeChange = (type: InputType) => {
|
||||
setInputType(type);
|
||||
setSelectedFile(null);
|
||||
setInputUrl('');
|
||||
resetResults();
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const validation = validateFile(file, inputType);
|
||||
if (!validation.valid) {
|
||||
setFileError(validation.error);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
return;
|
||||
}
|
||||
setFileError(null);
|
||||
setSelectedFile(file);
|
||||
};
|
||||
|
||||
const handleRemoveFile = () => {
|
||||
setSelectedFile(null);
|
||||
setFileError(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleAnalyze = () => {
|
||||
resetResults();
|
||||
setStartedAt(Date.now());
|
||||
if (inputType === 'text') {
|
||||
const txt = inputText.trim();
|
||||
setAnalyzedInput({ type: inputType, text: txt });
|
||||
async.submitText(txt);
|
||||
} else if (inputType === 'url') {
|
||||
const url = inputUrl.trim();
|
||||
setAnalyzedInput({ type: inputType, text: url });
|
||||
async.submitUrl(url);
|
||||
} else if (selectedFile) {
|
||||
setAnalyzedInput({ type: inputType, text: selectedFile.name });
|
||||
async.submitMedia(selectedFile, inputType);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleComponent = (name: string) => {
|
||||
setExpandedComponents(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const isRunning = async.isAnalyzing;
|
||||
const error = async.error;
|
||||
const progress = async.progress;
|
||||
const { skipped, skipMessage, warnings, statusText } = async;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Header>
|
||||
<Title>{t('pipeline.title')}</Title>
|
||||
<Subtitle>{t('pipeline.subtitle')}</Subtitle>
|
||||
</Header>
|
||||
|
||||
<InputForm
|
||||
inputType={inputType}
|
||||
onTypeChange={handleTypeChange}
|
||||
inputText={inputText}
|
||||
onTextChange={setInputText}
|
||||
inputUrl={inputUrl}
|
||||
onUrlChange={setInputUrl}
|
||||
selectedFile={selectedFile}
|
||||
fileError={fileError}
|
||||
fileInputRef={fileInputRef}
|
||||
onFileSelect={handleFileSelect}
|
||||
onRemoveFile={handleRemoveFile}
|
||||
isRunning={isRunning}
|
||||
statusMsg={statusText}
|
||||
onAnalyze={handleAnalyze}
|
||||
/>
|
||||
|
||||
{error && <ErrorBox><ErrorIcon>!</ErrorIcon>{error}</ErrorBox>}
|
||||
|
||||
{skipped && skipMessage && (
|
||||
<SkipBox>
|
||||
<SkipText>{skipMessage}</SkipText>
|
||||
<SkipAction onClick={() => { async.reset(); handleTypeChange('image'); }}>
|
||||
{t('common.uploadManual')}
|
||||
</SkipAction>
|
||||
</SkipBox>
|
||||
)}
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<WarningBox>
|
||||
{warnings.map((w, i) => <div key={i}>{w}</div>)}
|
||||
</WarningBox>
|
||||
)}
|
||||
|
||||
{(isRunning || progress > 0) && !verdict && (
|
||||
<LoadingState
|
||||
progress={progress}
|
||||
components={components}
|
||||
fullResult={fullResult}
|
||||
startedAt={startedAt}
|
||||
statusMsg={statusText}
|
||||
/>
|
||||
)}
|
||||
|
||||
{verdict && analyzedInput && (
|
||||
<InputPreview inputType={analyzedInput.type} text={analyzedInput.text} />
|
||||
)}
|
||||
|
||||
{verdict && analysisSession && (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginBottom: 12 }}>
|
||||
{analysisSession.session_id && (
|
||||
<ShareReportButton sessionId={analysisSession.session_id} />
|
||||
)}
|
||||
<DownloadPdfButton session={analysisSession} techDefs={techniqueDefinitions} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{verdict && (
|
||||
<Verdict
|
||||
verdict={verdict}
|
||||
totalDuration={totalDuration}
|
||||
components={components}
|
||||
fullResult={fullResult}
|
||||
techniqueDefinitions={techniqueDefinitions}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
99
web/src/components/PipelineAnalysis/details/ai.tsx
Normal file
99
web/src/components/PipelineAnalysis/details/ai.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import React from 'react';
|
||||
import { Cpu, BarChart2 } from 'lucide-react';
|
||||
import type { FindingAccent } from '../sections/FindingCard';
|
||||
import { FindingCard } from '../sections/FindingCard';
|
||||
import { SectionDivider } from '../sections/SectionDivider';
|
||||
import { enumLabel } from '../../../utils/i18n-enums';
|
||||
|
||||
function aiAccent(prob: number): FindingAccent {
|
||||
if (prob >= 80) return 'critical';
|
||||
if (prob >= 60) return 'warning';
|
||||
if (prob >= 40) return 'info';
|
||||
return 'success';
|
||||
}
|
||||
|
||||
/** Editorial renderer used inside Verdict (Tier 4 expanded). */
|
||||
export function renderAiEditorial(data: any, isRo: boolean) {
|
||||
if (!data) return null;
|
||||
const prob = typeof data.ai_probability === 'number' ? data.ai_probability : (parseFloat(data.ai_probability) || 0);
|
||||
const verdict = typeof data.verdict === 'string' ? data.verdict : 'UNKNOWN';
|
||||
const verdictText = enumLabel(verdict).replace(/_/g, ' ');
|
||||
const indicators = data.indicators_detected || [];
|
||||
const imgIndicators = data.image_analysis?.indicators || [];
|
||||
|
||||
// Headline crafted: short + descriptive
|
||||
const headline = isRo
|
||||
? (prob >= 60
|
||||
? `Probabilitate ridicată de generare AI — ${prob.toFixed(0)}%`
|
||||
: prob >= 40
|
||||
? `Probabilitate moderată de generare AI — ${prob.toFixed(0)}%`
|
||||
: `Probabilitate scăzută de generare AI — ${prob.toFixed(0)}%`)
|
||||
: (prob >= 60
|
||||
? `High likelihood of AI generation — ${prob.toFixed(0)}%`
|
||||
: prob >= 40
|
||||
? `Moderate likelihood of AI generation — ${prob.toFixed(0)}%`
|
||||
: `Low likelihood of AI generation — ${prob.toFixed(0)}%`);
|
||||
|
||||
const eyebrowParts = [
|
||||
`${isRo ? 'Verdict' : 'Verdict'} · ${verdictText}`,
|
||||
data.disclosure_detected === true
|
||||
? (isRo ? 'cu declarație AI' : 'AI disclosure found')
|
||||
: data.disclosure_detected === false
|
||||
? (isRo ? 'fără declarație AI' : 'no AI disclosure')
|
||||
: null,
|
||||
data.coupling_context?.for_verdict?.confidence_level
|
||||
? `${isRo ? 'încredere' : 'confidence'} ${enumLabel(data.coupling_context.for_verdict.confidence_level).toLowerCase()}`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
// Quote: pick a representative indicator if any
|
||||
let quote: React.ReactNode | undefined;
|
||||
if (indicators.length > 0 && indicators[0].evidence) {
|
||||
quote = indicators[0].evidence;
|
||||
} else if (imgIndicators.length > 0) {
|
||||
quote = imgIndicators.slice(0, 3).join(' · ');
|
||||
}
|
||||
|
||||
const source = (
|
||||
<>
|
||||
<BarChart2 size={13} strokeWidth={2} />
|
||||
<span>{prob.toFixed(0)}% {isRo ? 'probabilitate' : 'probability'}</span>
|
||||
{indicators.length > 0 && (
|
||||
<>
|
||||
<span className="sep">·</span>
|
||||
<span>{indicators.length} {isRo ? 'indicatori' : 'indicators'}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionDivider label={isRo ? 'Detectare AI' : 'AI detection'} />
|
||||
<FindingCard
|
||||
icon={Cpu}
|
||||
accent={aiAccent(prob)}
|
||||
eyebrow={eyebrowParts.join(' · ')}
|
||||
headline={headline}
|
||||
quote={quote}
|
||||
source={source}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Backward-compat. */
|
||||
export function renderAiDetail(data: any, _t: any) {
|
||||
const isRo = (typeof document !== 'undefined' && document.documentElement.lang || '').toLowerCase().startsWith('ro');
|
||||
return <>{renderAiEditorial(data, isRo)}</>;
|
||||
}
|
||||
|
||||
export function renderAiSummary(data: any, t: any) {
|
||||
if (!data) return null;
|
||||
const prob = typeof data.ai_probability === 'number' ? data.ai_probability : (parseFloat(data.ai_probability) || 0);
|
||||
return (
|
||||
<span style={{ fontSize: 12, color: 'var(--fg-secondary)' }}>
|
||||
{prob.toFixed(0)}% AI · {t('pipeline.aiProbabilityLabel')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
162
web/src/components/PipelineAnalysis/details/claims.tsx
Normal file
162
web/src/components/PipelineAnalysis/details/claims.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import React from 'react';
|
||||
import { XCircle, CheckCircle2, HelpCircle, Plus, ChevronDown } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { localized } from '../../../utils/i18n-fields';
|
||||
import { enumLabel } from '../../../utils/i18n-enums';
|
||||
import { FindingCard, type FindingAccent } from '../sections/FindingCard';
|
||||
import { SectionDivider } from '../sections/SectionDivider';
|
||||
import {
|
||||
CollapseBtn, CollapseBtnLeft, CollapseBtnChevron,
|
||||
StancePill, FindingSourceLink,
|
||||
} from '../styles';
|
||||
|
||||
const DEFAULT_VISIBLE = 4;
|
||||
|
||||
function claimIcon(status: string, statusColor?: string): LucideIcon {
|
||||
if (status === 'verified_false' || statusColor === 'red') return XCircle;
|
||||
if (status === 'verified_true' || statusColor === 'green') return CheckCircle2;
|
||||
return HelpCircle;
|
||||
}
|
||||
|
||||
function claimAccent(status: string, statusColor?: string): FindingAccent {
|
||||
if (status === 'verified_false' || statusColor === 'red') return 'critical';
|
||||
if (status === 'verified_true' || statusColor === 'green') return 'success';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
function stanceFromString(s: string): 'contradicts' | 'supports' | 'neutral' {
|
||||
const u = (s || '').toUpperCase();
|
||||
if (u === 'CONTRADICTS') return 'contradicts';
|
||||
if (u === 'SUPPORTS') return 'supports';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
function sourceHostname(url: string): string {
|
||||
return (url || '').replace(/^https?:\/\/(www\.)?/, '').split('/')[0];
|
||||
}
|
||||
|
||||
export function renderClaimsEditorial(
|
||||
data: any,
|
||||
isRo: boolean,
|
||||
expanded: boolean,
|
||||
onToggle: () => void,
|
||||
) {
|
||||
if (!data) return null;
|
||||
const claims = data.claims_verified || data.claims || [];
|
||||
if (claims.length === 0) return null;
|
||||
|
||||
const total = data.total_claims || claims.length;
|
||||
const trueCount = data.verified_true || 0;
|
||||
const falseCount = data.verified_false || 0;
|
||||
const unverified = data.unverified || 0;
|
||||
|
||||
const summaryParts = [
|
||||
`${total} ${isRo ? 'total' : 'total'}`,
|
||||
trueCount > 0 ? `${trueCount} ${isRo ? 'adevărate' : 'true'}` : null,
|
||||
falseCount > 0 ? `${falseCount} ${isRo ? 'false' : 'false'}` : null,
|
||||
unverified > 0 ? `${unverified} ${isRo ? 'neverificate' : 'unverified'}` : null,
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
const visible = expanded ? claims.length : Math.min(DEFAULT_VISIBLE, claims.length);
|
||||
const shown = claims.slice(0, visible);
|
||||
const remaining = claims.length - visible;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionDivider
|
||||
label={isRo ? 'Verificarea afirmațiilor' : 'Claim verification'}
|
||||
count={summaryParts}
|
||||
/>
|
||||
{shown.map((claim: any, i: number) => {
|
||||
const icon = claimIcon(claim.status, claim.status_color);
|
||||
const accent = claimAccent(claim.status, claim.status_color);
|
||||
const statusName = localized(claim, 'status_name') || enumLabel(claim.status || '');
|
||||
const typeName = claim.type_name || enumLabel(claim.type || '');
|
||||
const eyebrowParts = [
|
||||
statusName,
|
||||
typeName,
|
||||
claim.priority ? `${isRo ? 'prioritate' : 'priority'} ${claim.priority}` : null,
|
||||
].filter(Boolean);
|
||||
const sources = (claim.sources || []).slice(0, 6);
|
||||
return (
|
||||
<FindingCard
|
||||
key={claim.id || i}
|
||||
icon={icon}
|
||||
accent={accent}
|
||||
eyebrow={eyebrowParts.join(' · ')}
|
||||
headline={claim.text}
|
||||
quote={claim.reasoning}
|
||||
source={sources.length > 0 ? (
|
||||
<>
|
||||
{(() => {
|
||||
const dominantStance = sources[0]?.stance ? stanceFromString(sources[0].stance) : 'neutral';
|
||||
return (
|
||||
<StancePill stance={dominantStance}>
|
||||
{isRo
|
||||
? (dominantStance === 'contradicts' ? 'Contrazice' : dominantStance === 'supports' ? 'Susține' : 'Neutru')
|
||||
: (dominantStance === 'contradicts' ? 'Contradicts' : dominantStance === 'supports' ? 'Supports' : 'Neutral')}
|
||||
</StancePill>
|
||||
);
|
||||
})()}
|
||||
{sources.map((src: any, idx: number) => (
|
||||
<React.Fragment key={idx}>
|
||||
{idx > 0 && <span className="sep">·</span>}
|
||||
<FindingSourceLink href={src.url} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()}>
|
||||
{sourceHostname(src.url)}
|
||||
</FindingSourceLink>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{remaining > 0 && (
|
||||
<CollapseBtn onClick={onToggle}>
|
||||
<CollapseBtnLeft>
|
||||
<Plus size={16} strokeWidth={2} />
|
||||
<span>
|
||||
{isRo
|
||||
? `Vezi celelalte ${remaining} ${remaining === 1 ? 'afirmație' : 'afirmații'}`
|
||||
: `Show ${remaining} more ${remaining === 1 ? 'claim' : 'claims'}`}
|
||||
</span>
|
||||
</CollapseBtnLeft>
|
||||
<CollapseBtnChevron expanded={false}>
|
||||
<ChevronDown size={14} strokeWidth={2.5} />
|
||||
</CollapseBtnChevron>
|
||||
</CollapseBtn>
|
||||
)}
|
||||
{expanded && claims.length > DEFAULT_VISIBLE && (
|
||||
<CollapseBtn onClick={onToggle}>
|
||||
<CollapseBtnLeft>
|
||||
<ChevronDown size={16} strokeWidth={2} />
|
||||
<span>{isRo ? 'Ascunde' : 'Hide'}</span>
|
||||
</CollapseBtnLeft>
|
||||
<CollapseBtnChevron expanded={true}>
|
||||
<ChevronDown size={14} strokeWidth={2.5} />
|
||||
</CollapseBtnChevron>
|
||||
</CollapseBtn>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderClaimsDetail(data: any, _t: any) {
|
||||
const isRo = (typeof document !== 'undefined' && document.documentElement.lang || '').toLowerCase().startsWith('ro');
|
||||
return <RenderClaimsStatic data={data} isRo={isRo} />;
|
||||
}
|
||||
|
||||
const RenderClaimsStatic: React.FC<{ data: any; isRo: boolean }> = ({ data, isRo }) => {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
return <>{renderClaimsEditorial(data, isRo, expanded, () => setExpanded(o => !o))}</>;
|
||||
};
|
||||
|
||||
export function renderClaimsSummary(data: any, t: any) {
|
||||
if (!data) return null;
|
||||
return (
|
||||
<span style={{ fontSize: 12, color: 'var(--fg-secondary)' }}>
|
||||
{data.total_claims || 0} {t('pipeline.claimsLabel')}
|
||||
{data.verified_false > 0 ? ` · ${data.verified_false} ${t('pipeline.falseLabel')}` : ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
42
web/src/components/PipelineAnalysis/details/domain.tsx
Normal file
42
web/src/components/PipelineAnalysis/details/domain.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import React from 'react';
|
||||
import { Globe } from 'lucide-react';
|
||||
import { enumLabel } from '../../../utils/i18n-enums';
|
||||
import type { FindingAccent } from '../sections/FindingCard';
|
||||
import { FindingCard } from '../sections/FindingCard';
|
||||
|
||||
function domainAccent(trust: number): FindingAccent {
|
||||
if (trust >= 70) return 'success';
|
||||
if (trust >= 40) return 'warning';
|
||||
return 'critical';
|
||||
}
|
||||
|
||||
export function renderDomainDetail(data: any) {
|
||||
if (!data) return null;
|
||||
const trust = data.trust_score ?? 0;
|
||||
const verdictText = enumLabel(data.verdict || '') || 'Unknown';
|
||||
const isRo = (typeof document !== 'undefined' && document.documentElement.lang || '').toLowerCase().startsWith('ro');
|
||||
return (
|
||||
<FindingCard
|
||||
icon={Globe}
|
||||
accent={domainAccent(trust)}
|
||||
eyebrow={isRo ? `Domeniu · trust ${trust}` : `Domain · trust ${trust}`}
|
||||
headline={typeof data.domain === 'string' ? data.domain : data.domain?.name || (isRo ? 'Necunoscut' : 'Unknown')}
|
||||
quote={data.category}
|
||||
source={
|
||||
<span>{verdictText}</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderDomainSummary(data: any) {
|
||||
if (!data) return null;
|
||||
const trust = parseFloat(data.trust_score);
|
||||
const validTrust = !isNaN(trust) && trust >= 0;
|
||||
return (
|
||||
<span style={{ fontSize: 12, color: 'var(--fg-secondary)' }}>
|
||||
{data.domain && typeof data.domain === 'string' ? `${data.domain} · ` : ''}
|
||||
{validTrust ? `Trust ${trust}%` : 'Trust N/A'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
136
web/src/components/PipelineAnalysis/details/source.tsx
Normal file
136
web/src/components/PipelineAnalysis/details/source.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import React from 'react';
|
||||
import { ShieldAlert, AlertTriangle } from 'lucide-react';
|
||||
import { enumLabel } from '../../../utils/i18n-enums';
|
||||
import type { FindingAccent } from '../sections/FindingCard';
|
||||
import {
|
||||
FindingCardBox, FindingIconBox, FindingBody,
|
||||
FindingEyebrow, FindingHeadline, FindingSourceRow,
|
||||
ScoresBlock, ScoreLine, ScoreLineName, ScoreLineBar, ScoreLineFill, ScoreLineNum,
|
||||
} from '../styles';
|
||||
import { SectionDivider } from '../sections/SectionDivider';
|
||||
|
||||
const ACCENT_HEX: Record<FindingAccent, string> = {
|
||||
critical: '#ef4444',
|
||||
warning: '#f97316',
|
||||
info: '#3b82f6',
|
||||
success: '#22c55e',
|
||||
neutral: '#94a3b8',
|
||||
violet: '#7fd0d4',
|
||||
};
|
||||
|
||||
function trustAccent(trust: number): FindingAccent {
|
||||
if (trust >= 70) return 'success';
|
||||
if (trust >= 40) return 'warning';
|
||||
return 'critical';
|
||||
}
|
||||
|
||||
function barColor(score: number): string {
|
||||
return score >= 70 ? '#22c55e' : score >= 40 ? '#f97316' : '#ef4444';
|
||||
}
|
||||
|
||||
export function renderSourceEditorial(data: any, isRo: boolean) {
|
||||
if (!data) return null;
|
||||
const trust = data.trust_score ?? 0;
|
||||
const accent = trustAccent(trust);
|
||||
const accentHex = ACCENT_HEX[accent];
|
||||
|
||||
const verdictLabel = enumLabel(data.verdict || '').replace(/_/g, ' ');
|
||||
const riskLevel = enumLabel(data.risk_level || '');
|
||||
|
||||
const pub = data.publication || {};
|
||||
const auth = data.author || {};
|
||||
const plat = data.platform || {};
|
||||
const dom = data.domain || {};
|
||||
const formula = data.formula || {};
|
||||
void formula;
|
||||
|
||||
const headline = isRo
|
||||
? `${verdictLabel} — încredere ${trust}/100${riskLevel ? ` · risc ${riskLevel.toLowerCase()}` : ''}`
|
||||
: `${verdictLabel} — trust ${trust}/100${riskLevel ? ` · ${riskLevel.toLowerCase()} risk` : ''}`;
|
||||
|
||||
const axes = [
|
||||
{
|
||||
name: isRo ? 'Publicație' : 'Publication',
|
||||
score: pub.score ?? 0,
|
||||
weight: Math.round((formula.publication_weight ?? 0.35) * 100),
|
||||
},
|
||||
{
|
||||
name: isRo ? 'Autor' : 'Author',
|
||||
score: auth.score ?? 0,
|
||||
weight: Math.round((formula.author_weight ?? 0.25) * 100),
|
||||
},
|
||||
{
|
||||
name: isRo ? 'Platformă' : 'Platform',
|
||||
score: plat.score ?? 0,
|
||||
weight: Math.round((formula.platform_weight ?? 0.15) * 100),
|
||||
},
|
||||
{
|
||||
name: isRo ? 'Domeniu' : 'Domain',
|
||||
score: dom.score ?? 0,
|
||||
weight: Math.round((formula.domain_weight ?? 0.25) * 100),
|
||||
},
|
||||
];
|
||||
|
||||
const flags: string[] = [];
|
||||
if (!pub.confirmed) flags.push(isRo ? 'fără publicație confirmată' : 'no confirmed publication');
|
||||
if (!auth.confirmed) flags.push(isRo ? 'fără autor identificat' : 'no identified author');
|
||||
if (!dom.name || dom.name === 'N/A') flags.push(isRo ? 'fără domeniu' : 'no domain');
|
||||
if (data.red_flags?.length) flags.push(...data.red_flags.map((f: string) => f.replace(/_/g, ' ').toLowerCase()));
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionDivider
|
||||
label={isRo ? 'Evaluarea sursei' : 'Source assessment'}
|
||||
count={`${trust} / 100 · ${verdictLabel.toLowerCase()}`}
|
||||
/>
|
||||
<FindingCardBox>
|
||||
<FindingIconBox accent={accentHex} lg>
|
||||
<ShieldAlert size={20} strokeWidth={2} />
|
||||
</FindingIconBox>
|
||||
<FindingBody>
|
||||
<FindingEyebrow>{verdictLabel}</FindingEyebrow>
|
||||
<FindingHeadline>{headline}</FindingHeadline>
|
||||
|
||||
<ScoresBlock style={{ marginTop: 4 }}>
|
||||
{axes.map((ax, i) => (
|
||||
<ScoreLine key={ax.name}>
|
||||
<ScoreLineName>{ax.name}</ScoreLineName>
|
||||
<ScoreLineBar>
|
||||
<ScoreLineFill width={ax.score} color={barColor(ax.score)} delay={400 + i * 60} />
|
||||
</ScoreLineBar>
|
||||
<ScoreLineNum>{ax.score}</ScoreLineNum>
|
||||
</ScoreLine>
|
||||
))}
|
||||
</ScoresBlock>
|
||||
|
||||
{flags.length > 0 && (
|
||||
<FindingSourceRow>
|
||||
<AlertTriangle size={13} strokeWidth={2} />
|
||||
{flags.map((f, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
{idx > 0 && <span className="sep">·</span>}
|
||||
<span>{f}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</FindingSourceRow>
|
||||
)}
|
||||
</FindingBody>
|
||||
</FindingCardBox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderSourceDetail(data: any) {
|
||||
const isRo = (typeof document !== 'undefined' && document.documentElement.lang || '').toLowerCase().startsWith('ro');
|
||||
return <>{renderSourceEditorial(data, isRo)}</>;
|
||||
}
|
||||
|
||||
export function renderSourceSummary(data: any) {
|
||||
if (!data) return null;
|
||||
const trust = data.trust_score ?? 0;
|
||||
return (
|
||||
<span style={{ fontSize: 12, color: 'var(--fg-secondary)' }}>
|
||||
Trust {trust}% · {enumLabel(data.verdict || '').replace(/_/g, ' ')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
131
web/src/components/PipelineAnalysis/details/techniques.tsx
Normal file
131
web/src/components/PipelineAnalysis/details/techniques.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import React from 'react';
|
||||
import { AlertCircle, Cpu, Layers, Target, Plus, ChevronDown } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { TechniqueDefinitionsService, type TechniqueDefinition } from '../../../services/technique-definitions.service';
|
||||
import { localized } from '../../../utils/i18n-fields';
|
||||
import { formatTechName } from '../utils';
|
||||
import { FindingCard, type FindingAccent } from '../sections/FindingCard';
|
||||
import { SectionDivider } from '../sections/SectionDivider';
|
||||
import { CollapseBtn, CollapseBtnLeft, CollapseBtnChevron } from '../styles';
|
||||
|
||||
const DEFAULT_VISIBLE = 5;
|
||||
|
||||
/** Map tech name dimension prefix → icon. */
|
||||
function techIcon(name: string): LucideIcon {
|
||||
if (!name) return AlertCircle;
|
||||
const dim = name.split('.')[0]?.toUpperCase();
|
||||
if (dim === 'D3') return Cpu;
|
||||
if (dim === 'D5') return Layers;
|
||||
if (dim === 'D8') return Target;
|
||||
return AlertCircle;
|
||||
}
|
||||
|
||||
function techAccent(severity: number): FindingAccent {
|
||||
if (severity >= 70) return 'critical';
|
||||
if (severity >= 40) return 'warning';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
/** Editorial renderer used inside Verdict (Tier 4 expanded). */
|
||||
export function renderTechniquesEditorial(
|
||||
data: any,
|
||||
definitions: TechniqueDefinition[],
|
||||
isRo: boolean,
|
||||
expanded: boolean,
|
||||
onToggle: () => void,
|
||||
) {
|
||||
if (!data) return null;
|
||||
const techs = data.techniques_detected || [];
|
||||
if (techs.length === 0) return null;
|
||||
|
||||
const dims = (data.dimensions_affected || []).join(' · ');
|
||||
const total = techs.length;
|
||||
const visible = expanded ? total : Math.min(DEFAULT_VISIBLE, total);
|
||||
const shown = techs.slice(0, visible);
|
||||
const remaining = total - visible;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionDivider
|
||||
label={isRo ? 'Tehnici de manipulare' : 'Manipulation techniques'}
|
||||
count={`${total} ${isRo ? 'detectate' : 'detected'}${dims ? ` · ${dims}` : ''}`}
|
||||
/>
|
||||
{shown.map((tech: any, i: number) => {
|
||||
const def = TechniqueDefinitionsService.findByName(definitions, tech.name || '');
|
||||
const dimName = localized(tech, 'dimension_name', 'dimension');
|
||||
const subdimName = localized(tech, 'subdimension_name', 'subdimension');
|
||||
const eyebrowParts = [
|
||||
tech.name?.split('.')[0]?.toUpperCase(),
|
||||
dimName ? (subdimName ? `${dimName} · ${subdimName}` : dimName) : null,
|
||||
tech.severity != null ? `${isRo ? 'severitate' : 'severity'} ${tech.severity}` : null,
|
||||
].filter(Boolean);
|
||||
const description = def ? (localized(def, 'description') as string | undefined) : undefined;
|
||||
return (
|
||||
<FindingCard
|
||||
key={i}
|
||||
icon={techIcon(tech.name || '')}
|
||||
accent={techAccent(tech.severity || 0)}
|
||||
eyebrow={eyebrowParts.join(' · ')}
|
||||
headline={formatTechName(localized(tech, 'technique_name', 'name'))}
|
||||
info={description}
|
||||
quote={tech.evidence}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{remaining > 0 && (
|
||||
<CollapseBtn onClick={onToggle}>
|
||||
<CollapseBtnLeft>
|
||||
<Plus size={16} strokeWidth={2} />
|
||||
<span>
|
||||
{isRo
|
||||
? `Vezi celelalte ${remaining} ${remaining === 1 ? 'tehnică' : 'tehnici'}`
|
||||
: `Show ${remaining} more ${remaining === 1 ? 'technique' : 'techniques'}`}
|
||||
</span>
|
||||
</CollapseBtnLeft>
|
||||
<CollapseBtnChevron expanded={false}>
|
||||
<ChevronDown size={14} strokeWidth={2.5} />
|
||||
</CollapseBtnChevron>
|
||||
</CollapseBtn>
|
||||
)}
|
||||
{expanded && total > DEFAULT_VISIBLE && (
|
||||
<CollapseBtn onClick={onToggle}>
|
||||
<CollapseBtnLeft>
|
||||
<ChevronDown size={16} strokeWidth={2} />
|
||||
<span>{isRo ? 'Ascunde' : 'Hide'}</span>
|
||||
</CollapseBtnLeft>
|
||||
<CollapseBtnChevron expanded={true}>
|
||||
<ChevronDown size={14} strokeWidth={2.5} />
|
||||
</CollapseBtnChevron>
|
||||
</CollapseBtn>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Backward-compat wrapper for History.tsx. */
|
||||
export function renderTechniquesDetail(data: any, definitions: TechniqueDefinition[] = [], _t: any) {
|
||||
// Pages like History stand in for `t` but we rely on document.documentElement lang
|
||||
const isRo = (typeof document !== 'undefined' && document.documentElement.lang || '').toLowerCase().startsWith('ro');
|
||||
// Render expanded by default in static history view (no toggle needed there)
|
||||
return (
|
||||
<RenderTechniquesStatic data={data} definitions={definitions} isRo={isRo} />
|
||||
);
|
||||
}
|
||||
|
||||
const RenderTechniquesStatic: React.FC<{ data: any; definitions: TechniqueDefinition[]; isRo: boolean }> = ({ data, definitions, isRo }) => {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
return <>{renderTechniquesEditorial(data, definitions, isRo, expanded, () => setExpanded(o => !o))}</>;
|
||||
};
|
||||
|
||||
/** Summary chips used in compact lists (preserved for legacy callers). */
|
||||
export function renderTechniquesSummary(data: any, t: any) {
|
||||
if (!data) return null;
|
||||
const count = (data.techniques_detected || []).length;
|
||||
if (count === 0) return null;
|
||||
const score = typeof data.manipulation_score === 'number' ? data.manipulation_score : (parseFloat(data.manipulation_score) || 0);
|
||||
return (
|
||||
<span style={{ fontSize: 12, color: 'var(--fg-secondary)' }}>
|
||||
{count} {t('pipeline.techniqueCount')}{score > 0 ? ` · ${t('common.score')} ${score.toFixed(0)}` : ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
1
web/src/components/PipelineAnalysis/index.ts
Normal file
1
web/src/components/PipelineAnalysis/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { PipelineAnalysis } from './PipelineAnalysis';
|
||||
357
web/src/components/PipelineAnalysis/sections/AiResults.tsx
Normal file
357
web/src/components/PipelineAnalysis/sections/AiResults.tsx
Normal 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);
|
||||
}
|
||||
`;
|
||||
112
web/src/components/PipelineAnalysis/sections/ClaimsResults.tsx
Normal file
112
web/src/components/PipelineAnalysis/sections/ClaimsResults.tsx
Normal 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))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
80
web/src/components/PipelineAnalysis/sections/FindingCard.tsx
Normal file
80
web/src/components/PipelineAnalysis/sections/FindingCard.tsx
Normal 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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
139
web/src/components/PipelineAnalysis/sections/InputForm.tsx
Normal file
139
web/src/components/PipelineAnalysis/sections/InputForm.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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; }
|
||||
`;
|
||||
210
web/src/components/PipelineAnalysis/sections/LoadingState.tsx
Normal file
210
web/src/components/PipelineAnalysis/sections/LoadingState.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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(' · ')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
453
web/src/components/PipelineAnalysis/sections/Verdict.tsx
Normal file
453
web/src/components/PipelineAnalysis/sections/Verdict.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
564
web/src/components/PipelineAnalysis/styles.ts
Normal file
564
web/src/components/PipelineAnalysis/styles.ts
Normal file
|
|
@ -0,0 +1,564 @@
|
|||
import styled from '@emotion/styled';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import { typography, spacing } from '../../theme';
|
||||
|
||||
/* =============================================================================
|
||||
LAYOUT + TOP HEADER
|
||||
============================================================================= */
|
||||
export const Container = styled.div`
|
||||
width: 100%; max-width: 1800px; margin: 0 auto;
|
||||
padding: 0;
|
||||
@media (max-width: 768px) { padding: 0; }
|
||||
`;
|
||||
export const Header = styled.div`margin-bottom: ${spacing.xl}px;`;
|
||||
export const Title = styled.h1`
|
||||
font-family: ${typography.fontFamily.primary}; font-size: clamp(1.375rem, 1.1rem + 1vw, 1.75rem);
|
||||
font-weight: ${typography.fontWeight.bold}; color: var(--fg-primary);
|
||||
margin: 0 0 ${spacing.xs}px 0;
|
||||
`;
|
||||
export const Subtitle = styled.p`
|
||||
font-family: ${typography.fontFamily.primary}; font-size: ${typography.fontSize.sm};
|
||||
color: var(--fg-muted); margin: 0;
|
||||
`;
|
||||
|
||||
/* =============================================================================
|
||||
INPUT FORM
|
||||
============================================================================= */
|
||||
export const TypeSelector = styled.div`
|
||||
display: flex; gap: 4px; padding: 4px;
|
||||
background: var(--bg-surface); border-radius: 12px;
|
||||
margin-bottom: ${spacing.lg}px; border: 1px solid var(--border-subtle);
|
||||
@media (max-width: 480px) { flex-direction: column; }
|
||||
`;
|
||||
export const TypeButton = styled.button<{ active?: boolean }>`
|
||||
flex: 1; padding: 10px 16px; border: none; border-radius: 8px; cursor: pointer;
|
||||
font-family: ${typography.fontFamily.primary}; font-size: ${typography.fontSize.sm};
|
||||
font-weight: ${p => p.active ? typography.fontWeight.semibold : typography.fontWeight.medium};
|
||||
transition: all 0.2s;
|
||||
background: ${p => p.active ? 'var(--accent-subtle)' : 'transparent'};
|
||||
color: ${p => p.active ? 'var(--accent-text)' : 'var(--fg-secondary)'};
|
||||
${p => p.active && 'box-shadow: 0 0 0 1px var(--accent-border);'}
|
||||
`;
|
||||
export const InputArea = styled.div`
|
||||
background: var(--bg-surface); border: 1px solid var(--border-default);
|
||||
border-radius: 14px; overflow: hidden; margin-bottom: ${spacing.lg}px;
|
||||
&:focus-within { border-color: var(--accent); }
|
||||
`;
|
||||
export const TextInput = styled.textarea`
|
||||
width: 100%; min-height: 140px; padding: ${spacing.lg}px;
|
||||
display: block; background: transparent;
|
||||
border: none; font-family: ${typography.fontFamily.primary}; font-size: 0.9375rem;
|
||||
color: var(--fg-primary); resize: vertical; box-sizing: border-box; line-height: 1.6;
|
||||
&:focus { outline: none; } &::placeholder { color: var(--fg-muted); }
|
||||
`;
|
||||
export const FileDropZone = styled.div`padding: ${spacing.lg}px; min-height: 140px; display: flex; align-items: center; justify-content: center;`;
|
||||
export const DropPlaceholder = styled.div`
|
||||
display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 40px;
|
||||
border: 2px dashed var(--border-default); border-radius: 12px; cursor: pointer; width: 100%;
|
||||
transition: all 0.2s;
|
||||
&:hover { border-color: var(--accent); background: var(--accent-subtle); }
|
||||
`;
|
||||
export const DropLabel = styled.span`font-size: 14px; color: var(--fg-secondary);`;
|
||||
export const DropHint = styled.span`font-size: 12px; color: var(--fg-subtle);`;
|
||||
export const FileErrorMsg = styled.div`font-size: 13px; color: #ef4444; margin-top: 8px; text-align: center;`;
|
||||
export const FileSelected = styled.div`
|
||||
display: flex; align-items: center; gap: ${spacing.md}px; padding: 14px 20px;
|
||||
background: var(--accent-subtle); border: 1px solid var(--accent-border); border-radius: 10px; width: 100%;
|
||||
`;
|
||||
export const FileName = styled.span`font-size: 14px; font-weight: 500; color: var(--fg-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;`;
|
||||
export const FileSize = styled.span`font-size: 12px; color: var(--fg-muted);`;
|
||||
export const RemoveFileBtn = styled.button`
|
||||
padding: 4px 10px; border: 1px solid rgba(239,68,68,0.3); border-radius: 6px;
|
||||
background: transparent; color: #f87171; font-size: 11px; font-weight: 600; cursor: pointer;
|
||||
&:hover { background: rgba(239,68,68,0.1); }
|
||||
[data-theme="light"] & { border-color: rgba(220,38,38,0.2); color: #dc2626; }
|
||||
`;
|
||||
export const UrlInputWrapper = styled.div`padding: ${spacing.lg}px; display: flex; flex-direction: column; gap: 8px;`;
|
||||
export const UrlInput = styled.input`
|
||||
width: 100%; padding: 14px ${spacing.lg}px; background: transparent;
|
||||
border: none; font-family: ${typography.fontFamily.primary}; font-size: 0.9375rem;
|
||||
color: var(--fg-primary); box-sizing: border-box;
|
||||
&:focus { outline: none; } &::placeholder { color: var(--fg-muted); }
|
||||
`;
|
||||
export const UrlHint = styled.div`
|
||||
font-size: 11px; color: var(--fg-subtle); padding: 0 ${spacing.lg}px;
|
||||
`;
|
||||
export const InputFooter = styled.div`
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: ${spacing.sm}px ${spacing.lg}px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
@media (max-width: 480px) {
|
||||
flex-direction: column; gap: 8px; padding: ${spacing.md}px;
|
||||
& > button { width: 100%; justify-content: center; }
|
||||
}
|
||||
`;
|
||||
export const CharCount = styled.span`font-size: 12px; color: var(--fg-subtle);`;
|
||||
|
||||
const spin = keyframes`from { transform: rotate(0deg); } to { transform: rotate(360deg); }`;
|
||||
export { spin };
|
||||
export const Spinner = styled.span`
|
||||
display: inline-block; width: 14px; height: 14px;
|
||||
border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff;
|
||||
border-radius: 50%; animation: ${spin} 0.7s linear infinite;
|
||||
`;
|
||||
export const AnalyzeBtn = styled.button<{ disabled?: boolean }>`
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 24px;
|
||||
background: ${p => p.disabled ? 'var(--accent-subtle)' : 'var(--accent)'};
|
||||
color: ${p => p.disabled ? 'var(--fg-disabled)' : 'var(--fg-on-accent)'};
|
||||
border: none; border-radius: 8px; font-family: ${typography.fontFamily.primary};
|
||||
font-size: ${typography.fontSize.sm}; font-weight: ${typography.fontWeight.semibold};
|
||||
cursor: ${p => p.disabled ? 'not-allowed' : 'pointer'}; transition: all 0.2s;
|
||||
&:hover:not(:disabled) { background: var(--accent-hover); transform: translateY(-1px); box-shadow: var(--shadow-md); }
|
||||
`;
|
||||
|
||||
/* =============================================================================
|
||||
BANNERS (error / skip / warning)
|
||||
============================================================================= */
|
||||
export const ErrorBox = styled.div`
|
||||
display: flex; align-items: center; gap: 10px; padding: 14px ${spacing.lg}px;
|
||||
background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.2);
|
||||
border-radius: 10px; color: #f87171; font-size: ${typography.fontSize.sm}; margin-bottom: ${spacing.lg}px;
|
||||
[data-theme="light"] & { background: #fef2f2; border-color: #fecaca; color: #dc2626; }
|
||||
`;
|
||||
export const ErrorIcon = styled.span`
|
||||
display: flex; align-items: center; justify-content: center; width: 20px; height: 20px;
|
||||
border-radius: 50%; background: rgba(239,68,68,0.2); font-size: 11px; font-weight: 700;
|
||||
`;
|
||||
export const SkipBox = styled.div`
|
||||
display: flex; flex-direction: column; gap: 10px; padding: 16px ${spacing.lg}px;
|
||||
background: rgba(234,179,8,0.08); border: 1px solid rgba(234,179,8,0.25);
|
||||
border-radius: 10px; margin-bottom: ${spacing.lg}px;
|
||||
[data-theme="light"] & { background: #fffbeb; border-color: #fde68a; }
|
||||
`;
|
||||
export const SkipText = styled.div`
|
||||
color: #eab308; font-size: ${typography.fontSize.sm}; line-height: 1.5;
|
||||
white-space: pre-line;
|
||||
[data-theme="light"] & { color: #b45309; }
|
||||
`;
|
||||
export const SkipAction = styled.button`
|
||||
align-self: flex-start; padding: 8px 16px; background: var(--accent-subtle);
|
||||
border: 1px solid var(--accent-border); border-radius: 8px; color: var(--accent-text);
|
||||
font-size: ${typography.fontSize.sm}; font-weight: ${typography.fontWeight.semibold};
|
||||
cursor: pointer; transition: all 0.2s;
|
||||
&:hover { border-color: var(--accent); }
|
||||
`;
|
||||
export const WarningBox = styled.div`
|
||||
padding: 12px ${spacing.lg}px;
|
||||
background: rgba(234, 179, 8, 0.08); border: 1px solid rgba(234, 179, 8, 0.2);
|
||||
border-radius: 10px; color: #eab308; font-size: ${typography.fontSize.sm};
|
||||
margin-bottom: ${spacing.lg}px; display: flex; flex-direction: column; gap: 4px;
|
||||
[data-theme="light"] & { background: #fefce8; border-color: #fde68a; color: #a16207; }
|
||||
`;
|
||||
|
||||
/* =============================================================================
|
||||
PROGRESS BAR
|
||||
============================================================================= */
|
||||
export const ProgressSection = styled.div`margin-bottom: ${spacing.lg}px;`;
|
||||
export const ProgressBar = styled.div`
|
||||
height: 6px; background: var(--bg-active); border-radius: 3px; overflow: hidden;
|
||||
`;
|
||||
export const ProgressFill = styled.div<{ width: number }>`
|
||||
height: 100%; width: ${p => p.width}%; background: var(--accent);
|
||||
border-radius: 3px; transition: width 0.5s ease;
|
||||
`;
|
||||
export const ProgressText = styled.div`
|
||||
font-size: 11px; color: var(--fg-muted); margin-top: 6px;
|
||||
display: flex; justify-content: space-between;
|
||||
`;
|
||||
|
||||
/* =============================================================================
|
||||
LOADING STATE — grid 2x2 cu micro-stats live
|
||||
============================================================================= */
|
||||
export const LoadingShell = styled.div`
|
||||
display: flex; flex-direction: column; gap: 16px;
|
||||
padding: 22px 24px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 16px;
|
||||
margin-bottom: ${spacing.lg}px;
|
||||
`;
|
||||
export const LoadingHeadline = styled.div`
|
||||
font-size: 13px; font-weight: 400; color: var(--fg-secondary);
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
letter-spacing: -0.005em;
|
||||
`;
|
||||
export const LoadingPulse = styled.span`
|
||||
display: inline-block; width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: ${keyframes`
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.35; transform: scale(0.8); }
|
||||
`} 1.4s ease-in-out infinite;
|
||||
`;
|
||||
export const LoadingGrid = styled.div`
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 8px;
|
||||
@media (max-width: 600px) { grid-template-columns: 1fr; }
|
||||
`;
|
||||
export const LoadingTile = styled.div<{ status: string }>`
|
||||
padding: 10px 12px; border-radius: 10px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
transition: opacity 0.2s ease;
|
||||
opacity: ${p => p.status === 'pending' ? 0.55 : 1};
|
||||
`;
|
||||
export const LoadingTileTop = styled.div`
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
`;
|
||||
export const LoadingTileLeft = styled.div`display: flex; align-items: center; gap: 8px;`;
|
||||
export const LoadingTileIcon = styled.span<{ status: string }>`
|
||||
font-size: 12px; line-height: 1; flex-shrink: 0;
|
||||
color: ${p =>
|
||||
p.status === 'completed' ? '#22c55e' :
|
||||
p.status === 'running' ? 'var(--accent)' :
|
||||
p.status === 'failed' ? '#ef4444' :
|
||||
'var(--fg-subtle)'};
|
||||
`;
|
||||
export const LoadingTileName = styled.span`
|
||||
font-size: 12.5px; font-weight: 500; color: var(--fg-primary);
|
||||
letter-spacing: -0.005em;
|
||||
`;
|
||||
export const LoadingTileDuration = styled.span`
|
||||
font-size: 10px; font-weight: 400;
|
||||
color: var(--fg-muted);
|
||||
`;
|
||||
export const LoadingTileMicro = styled.div<{ status: string }>`
|
||||
font-size: 11.5px; line-height: 1.4; padding-left: 20px;
|
||||
color: ${p =>
|
||||
p.status === 'completed' ? 'var(--fg-secondary)' :
|
||||
p.status === 'running' ? 'var(--accent-text)' :
|
||||
'var(--fg-subtle)'};
|
||||
letter-spacing: -0.005em;
|
||||
`;
|
||||
export const LoadingMiniSpinner = styled.span`
|
||||
display: inline-block; width: 9px; height: 9px;
|
||||
border: 1.5px solid var(--accent-border); border-top-color: var(--accent);
|
||||
border-radius: 50%; animation: ${spin} 0.7s linear infinite;
|
||||
margin-right: 4px; vertical-align: -1px;
|
||||
`;
|
||||
export const LoadingFindings = styled.div`
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
`;
|
||||
export const LoadingFindingsLabel = styled.div`
|
||||
font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: var(--fg-muted); font-weight: 500;
|
||||
`;
|
||||
export const LoadingFindingsRow = styled.div`
|
||||
font-size: 12.5px; color: var(--fg-secondary); display: flex; align-items: center; gap: 8px;
|
||||
letter-spacing: -0.005em;
|
||||
`;
|
||||
export const LoadingBullet = styled.span<{ color: string }>`
|
||||
display: inline-block; width: 5px; height: 5px; border-radius: 50%;
|
||||
background: ${p => p.color}; flex-shrink: 0;
|
||||
`;
|
||||
|
||||
/* =============================================================================
|
||||
═══════════════════════════════════════════════════════════════════════════
|
||||
VERDICT — EDITORIAL PATTERN (v3 redesign)
|
||||
═══════════════════════════════════════════════════════════════════════════
|
||||
============================================================================= */
|
||||
|
||||
export const VerdictSection = styled.div`
|
||||
display: flex; flex-direction: column; gap: 16px;
|
||||
margin-bottom: ${spacing.lg}px;
|
||||
|
||||
/* stagger animation pe încărcare */
|
||||
& > * { animation: vstagger 380ms cubic-bezier(0.2,0.8,0.2,1) backwards; }
|
||||
& > *:nth-of-type(1) { animation-delay: 0ms; }
|
||||
& > *:nth-of-type(2) { animation-delay: 160ms; }
|
||||
& > *:nth-of-type(3) { animation-delay: 240ms; }
|
||||
& > *:nth-of-type(4) { animation-delay: 320ms; }
|
||||
& > *:nth-of-type(5) { animation-delay: 400ms; }
|
||||
& > *:nth-of-type(6) { animation-delay: 480ms; }
|
||||
& > *:nth-of-type(7) { animation-delay: 560ms; }
|
||||
& > *:nth-of-type(8) { animation-delay: 640ms; }
|
||||
& > *:nth-of-type(9) { animation-delay: 720ms; }
|
||||
& > *:nth-of-type(n+10) { animation-delay: 800ms; }
|
||||
|
||||
@keyframes vstagger {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
`;
|
||||
|
||||
/* ─── Tier 1 — HEADLINE cu gauge ring ─── */
|
||||
export const HeadlineCard = styled.div<{ accent: string }>`
|
||||
display: flex; align-items: stretch; gap: 28px;
|
||||
padding: 32px 36px;
|
||||
background: linear-gradient(180deg, ${p => p.accent}0a, var(--bg-surface));
|
||||
border: 1px solid ${p => p.accent}1f;
|
||||
border-radius: 20px;
|
||||
position: relative; overflow: hidden;
|
||||
&::before {
|
||||
content: ''; position: absolute;
|
||||
top: -120px; right: -120px; width: 320px; height: 320px;
|
||||
background: radial-gradient(circle, ${p => p.accent}1f, transparent 60%);
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
flex-direction: column; gap: 20px; padding: 24px 22px; align-items: flex-start;
|
||||
}
|
||||
`;
|
||||
|
||||
export const GaugeCol = styled.div`
|
||||
flex-shrink: 0; display: flex; align-items: center; justify-content: center; width: 168px;
|
||||
@media (max-width: 720px) { width: auto; align-self: flex-start; }
|
||||
`;
|
||||
export const Gauge = styled.div<{ accent: string }>`
|
||||
width: 168px; height: 168px; position: relative; color: ${p => p.accent};
|
||||
svg { width: 100%; height: 100%; transform: rotate(-90deg); }
|
||||
@media (max-width: 720px) { width: 132px; height: 132px; }
|
||||
`;
|
||||
const gaugeFill = keyframes`
|
||||
to { stroke-dashoffset: var(--gauge-target, 0); }
|
||||
`;
|
||||
export const GaugeTrack = styled.circle`
|
||||
fill: none; stroke: currentColor; stroke-width: 6; opacity: 0.12;
|
||||
`;
|
||||
export const GaugeFill = styled.circle<{ scorePercent: number }>`
|
||||
fill: none; stroke: currentColor; stroke-width: 6; stroke-linecap: round;
|
||||
stroke-dasharray: 502.65;
|
||||
--gauge-target: ${p => 502.65 * (1 - Math.min(1, Math.max(0, p.scorePercent / 100)))};
|
||||
stroke-dashoffset: 502.65;
|
||||
animation: ${gaugeFill} 1100ms cubic-bezier(0.2,0.8,0.2,1) 200ms forwards;
|
||||
`;
|
||||
export const GaugeCenter = styled.div`
|
||||
position: absolute; inset: 0; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
animation: ${keyframes`from { opacity: 0; transform: scale(0.85); } to { opacity: 1; transform: scale(1); }`}
|
||||
600ms cubic-bezier(0.2,0.8,0.2,1) 1100ms backwards;
|
||||
`;
|
||||
export const GaugeScore = styled.div`
|
||||
font-size: 3.5rem; font-weight: 600; line-height: 1; letter-spacing: -0.04em; color: var(--fg-primary);
|
||||
font-family: ${typography.fontFamily.primary};
|
||||
@media (max-width: 720px) { font-size: 2.75rem; }
|
||||
`;
|
||||
export const GaugeOf = styled.div`
|
||||
margin-top: 6px; font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: var(--fg-subtle); font-weight: 500;
|
||||
`;
|
||||
|
||||
export const HeadlineContent = styled.div`
|
||||
flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 16px; padding-top: 6px;
|
||||
`;
|
||||
export const HeadlineEyebrow = styled.div`
|
||||
font-size: 10.5px; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase;
|
||||
color: var(--fg-muted);
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
&::before { content: ''; width: 18px; height: 1px; background: var(--border-strong); }
|
||||
`;
|
||||
export const CategoryRow = styled.div`
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
`;
|
||||
export const CategoryIconBox = styled.span<{ accent: string }>`
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 32px; height: 32px; border-radius: 10px;
|
||||
background: ${p => p.accent}26; color: ${p => p.accent};
|
||||
`;
|
||||
export const CategoryName = styled.span`
|
||||
font-size: 1.5rem; font-weight: 600; letter-spacing: -0.02em; color: var(--fg-primary);
|
||||
@media (max-width: 720px) { font-size: 1.25rem; }
|
||||
`;
|
||||
export const CategoryDesc = styled.span`
|
||||
font-size: 13px; color: var(--fg-secondary); display: flex; align-items: center; gap: 6px;
|
||||
& .sep { opacity: 0.4; }
|
||||
`;
|
||||
export const TldrText = styled.p`
|
||||
font-family: 'Merriweather', Georgia, serif; font-style: italic;
|
||||
font-size: 1.125rem; line-height: 1.55; font-weight: 400;
|
||||
color: var(--fg-primary); max-width: 60ch; margin: 0;
|
||||
@media (max-width: 720px) { font-size: 1rem; font-style: normal; }
|
||||
`;
|
||||
|
||||
/* ─── Tier 2 — ACTION CALLOUT ─── */
|
||||
export const ActionCallout = styled.div<{ accent: string }>`
|
||||
display: flex; gap: 20px; align-items: flex-start;
|
||||
padding: 22px 26px;
|
||||
background: linear-gradient(135deg, ${p => p.accent}29, ${p => p.accent}0f);
|
||||
border: 1px solid ${p => p.accent}47;
|
||||
border-radius: 16px;
|
||||
[data-theme="light"] & {
|
||||
background: linear-gradient(135deg, ${p => p.accent}1a, ${p => p.accent}0a);
|
||||
}
|
||||
`;
|
||||
export const ActionIconBox = styled.span<{ accent: string }>`
|
||||
flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 44px; height: 44px; border-radius: 14px;
|
||||
background: ${p => p.accent}38; color: ${p => p.accent};
|
||||
`;
|
||||
export const ActionBody = styled.div`flex: 1; display: flex; flex-direction: column; gap: 4px;`;
|
||||
export const ActionEyebrow = styled.div<{ accent: string }>`
|
||||
font-size: 11px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: ${p => p.accent};
|
||||
`;
|
||||
export const ActionHeadline = styled.div`
|
||||
font-size: 1.25rem; font-weight: 600; color: var(--fg-primary); letter-spacing: -0.02em; line-height: 1.25;
|
||||
@media (max-width: 720px) { font-size: 1.125rem; }
|
||||
`;
|
||||
export const ActionText = styled.div`
|
||||
margin-top: 6px; font-size: 0.90625rem; line-height: 1.5;
|
||||
color: var(--fg-secondary); max-width: 60ch;
|
||||
`;
|
||||
|
||||
/* ─── SECTION DIVIDER ─── */
|
||||
export const SectionDividerRow = styled.div`
|
||||
display: flex; align-items: center; gap: 14px; padding: 14px 4px 6px;
|
||||
`;
|
||||
export const SectionDividerLabel = styled.span`
|
||||
font-size: 10.5px; font-weight: 600; letter-spacing: 0.18em; text-transform: uppercase;
|
||||
color: var(--fg-muted);
|
||||
`;
|
||||
export const SectionDividerLine = styled.span`
|
||||
flex: 1; height: 1px; background: var(--border-subtle);
|
||||
`;
|
||||
export const SectionDividerCount = styled.span`
|
||||
font-size: 11px; color: var(--fg-subtle); font-weight: 500;
|
||||
`;
|
||||
|
||||
/* ─── FINDING CARD universal ─── */
|
||||
export const FindingCardBox = styled.div<{ dominant?: boolean }>`
|
||||
display: flex; gap: 16px;
|
||||
padding: ${p => p.dominant ? '22px 24px' : '20px 22px'};
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid ${p => p.dominant ? 'rgba(239,68,68,0.2)' : 'var(--border-subtle)'};
|
||||
border-radius: 16px;
|
||||
`;
|
||||
export const FindingIconBox = styled.span<{ accent: string; lg?: boolean }>`
|
||||
flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center;
|
||||
width: ${p => p.lg ? '40px' : '36px'};
|
||||
height: ${p => p.lg ? '40px' : '36px'};
|
||||
border-radius: ${p => p.lg ? '12px' : '11px'};
|
||||
background: ${p => p.accent}1a; color: ${p => p.accent};
|
||||
`;
|
||||
export const FindingBody = styled.div`flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px;`;
|
||||
export const FindingEyebrow = styled.div`
|
||||
font-size: 10.5px; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: var(--fg-muted);
|
||||
`;
|
||||
export const FindingHeadline = styled.div<{ dominant?: boolean }>`
|
||||
font-size: ${p => p.dominant ? '1.03125rem' : '0.96875rem'};
|
||||
line-height: 1.5; font-weight: 500;
|
||||
color: var(--fg-primary); letter-spacing: -0.01em;
|
||||
overflow-wrap: anywhere; word-break: break-word;
|
||||
`;
|
||||
export const FindingQuote = styled.p`
|
||||
font-family: 'Merriweather', Georgia, serif; font-style: italic;
|
||||
font-size: 0.875rem; line-height: 1.55; color: var(--fg-secondary);
|
||||
padding-left: 14px; border-left: 2px solid var(--border-default);
|
||||
max-width: 60ch; margin: 0;
|
||||
overflow-wrap: anywhere; word-break: break-word;
|
||||
`;
|
||||
export const FindingSourceRow = styled.div`
|
||||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||
font-size: 12.5px; color: var(--fg-muted); letter-spacing: -0.005em;
|
||||
min-width: 0; max-width: 100%;
|
||||
& .sep { color: var(--fg-subtle); }
|
||||
& svg { flex-shrink: 0; }
|
||||
`;
|
||||
export const FindingSourceLink = styled.a`
|
||||
color: var(--accent-text); text-decoration: none; font-weight: 500;
|
||||
overflow-wrap: anywhere; word-break: break-all; max-width: 100%;
|
||||
&:hover { text-decoration: underline; }
|
||||
`;
|
||||
export const StancePill = styled.span<{ stance: string }>`
|
||||
font-size: 9.5px; font-weight: 700; padding: 2px 7px; border-radius: 4px;
|
||||
text-transform: uppercase; letter-spacing: 0.04em; flex-shrink: 0;
|
||||
background: ${p => p.stance === 'contradicts' ? 'rgba(239,68,68,0.12)' :
|
||||
p.stance === 'supports' ? 'rgba(34,197,94,0.12)' :
|
||||
'rgba(148,163,184,0.12)'};
|
||||
color: ${p => p.stance === 'contradicts' ? '#ef4444' :
|
||||
p.stance === 'supports' ? '#22c55e' :
|
||||
'#94a3b8'};
|
||||
`;
|
||||
|
||||
/* ─── COLLAPSE button ─── */
|
||||
export const CollapseBtn = styled.button`
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
width: 100%; padding: 14px 20px; cursor: pointer;
|
||||
background: var(--bg-surface); border: 1px solid var(--border-subtle);
|
||||
border-radius: 14px; color: var(--fg-secondary);
|
||||
font-family: inherit; font-size: 13px; font-weight: 500;
|
||||
text-align: left; letter-spacing: -0.005em;
|
||||
transition: all 0.18s ease;
|
||||
&:hover { background: var(--bg-hover); color: var(--fg-primary); border-color: var(--border-default); }
|
||||
`;
|
||||
export const CollapseBtnLeft = styled.span`
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
& svg { opacity: 0.7; }
|
||||
`;
|
||||
export const CollapseBtnChevron = styled.span<{ expanded: boolean }>`
|
||||
display: inline-flex;
|
||||
transition: transform 0.2s ease;
|
||||
transform: rotate(${p => p.expanded ? '180deg' : '0deg'});
|
||||
& svg { opacity: 0.6; }
|
||||
`;
|
||||
|
||||
/* ─── STAT BLOCK (score bars + chips) ─── */
|
||||
export const StatBlock = styled.div`
|
||||
display: flex; flex-direction: column; gap: 16px;
|
||||
padding: 22px 24px;
|
||||
background: var(--bg-surface); border: 1px solid var(--border-subtle);
|
||||
border-radius: 16px;
|
||||
`;
|
||||
export const ScoresBlock = styled.div`display: flex; flex-direction: column; gap: 10px;`;
|
||||
export const ScoreLine = styled.div`display: flex; align-items: center; gap: 14px;`;
|
||||
export const ScoreLineName = styled.span`
|
||||
font-size: 13px; color: var(--fg-secondary); width: 110px; font-weight: 400;
|
||||
letter-spacing: -0.005em;
|
||||
@media (max-width: 600px) { width: 90px; }
|
||||
`;
|
||||
export const ScoreLineBar = styled.div`
|
||||
flex: 1; height: 4px; background: var(--bg-active); border-radius: 2px; overflow: hidden;
|
||||
`;
|
||||
const barFill = keyframes`from { width: 0; }`;
|
||||
export const ScoreLineFill = styled.div<{ width: number; color: string; delay?: number }>`
|
||||
height: 100%; width: ${p => Math.min(p.width, 100)}%; background: ${p => p.color};
|
||||
border-radius: 2px;
|
||||
animation: ${barFill} 900ms cubic-bezier(0.2,0.8,0.2,1) backwards;
|
||||
animation-delay: ${p => p.delay ?? 0}ms;
|
||||
`;
|
||||
export const ScoreLineNum = styled.span`
|
||||
font-size: 13px; font-weight: 500; color: var(--fg-primary);
|
||||
width: 40px; text-align: right; letter-spacing: -0.01em;
|
||||
`;
|
||||
export const ScoreLineSkipped = styled.span`
|
||||
font-size: 13px; color: var(--fg-subtle); width: 40px; text-align: right;
|
||||
`;
|
||||
|
||||
export const ChipsRow = styled.div`display: flex; gap: 6px; flex-wrap: wrap;`;
|
||||
export const Chip = styled.span<{ variant?: 'critical' | 'warning' | 'success' | 'info' | 'violet' | 'neutral' }>`
|
||||
font-size: 12px; font-weight: 400; padding: 5px 12px; border-radius: 999px;
|
||||
display: inline-flex; align-items: center; gap: 6px; letter-spacing: -0.005em;
|
||||
background: ${p => {
|
||||
switch (p.variant) {
|
||||
case 'critical': return 'rgba(239,68,68,0.12)';
|
||||
case 'warning': return 'rgba(249,115,22,0.12)';
|
||||
case 'success': return 'rgba(34,197,94,0.12)';
|
||||
case 'info': return 'rgba(59,130,246,0.12)';
|
||||
case 'violet': return 'var(--accent-subtle)';
|
||||
case 'neutral': return 'rgba(148,163,184,0.12)';
|
||||
default: return 'var(--accent-subtle)';
|
||||
}
|
||||
}};
|
||||
color: ${p => {
|
||||
switch (p.variant) {
|
||||
case 'critical': return '#ef4444';
|
||||
case 'warning': return '#f97316';
|
||||
case 'success': return '#22c55e';
|
||||
case 'info': return '#3b82f6';
|
||||
case 'violet': return 'var(--accent-text)';
|
||||
case 'neutral': return '#94a3b8';
|
||||
default: return 'var(--accent-text)';
|
||||
}
|
||||
}};
|
||||
`;
|
||||
|
||||
/* ─── LEGACY fallback (sesiuni vechi fără verdict_summary) ─── */
|
||||
export const LegacyExplanation = styled.div`
|
||||
font-family: 'Merriweather', Georgia, serif;
|
||||
font-size: 0.875rem; line-height: 1.6; color: var(--fg-secondary);
|
||||
padding: 16px 20px; background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle); border-radius: 16px;
|
||||
`;
|
||||
6
web/src/components/PipelineAnalysis/types.ts
Normal file
6
web/src/components/PipelineAnalysis/types.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export type InputType = 'text' | 'image' | 'audio' | 'video' | 'url';
|
||||
|
||||
export interface ComponentStatus {
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
duration_ms?: number;
|
||||
}
|
||||
83
web/src/components/PipelineAnalysis/utils.ts
Normal file
83
web/src/components/PipelineAnalysis/utils.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import type { InputType } from './types';
|
||||
|
||||
export const INPUT_TYPES: { key: InputType; labelKey: string; accept?: string }[] = [
|
||||
{ key: 'text', labelKey: 'common.text' },
|
||||
{ key: 'url', labelKey: 'common.url' },
|
||||
{ key: 'image', labelKey: 'common.image', accept: 'image/jpeg,image/png,image/webp,image/gif' },
|
||||
{ key: 'audio', labelKey: 'common.audio', accept: 'audio/mpeg,audio/wav,audio/ogg,audio/mp4' },
|
||||
{ key: 'video', labelKey: 'common.video', accept: 'video/mp4,video/webm,video/ogg' },
|
||||
];
|
||||
|
||||
export const COMPONENT_LABEL_KEYS: Record<string, string> = {
|
||||
techniques: 'pipeline.componentNames.techniques',
|
||||
ai_tampered: 'pipeline.componentNames.aiTamper',
|
||||
claims: 'pipeline.componentNames.claims',
|
||||
domain: 'pipeline.componentNames.domain',
|
||||
source_assessment: 'pipeline.componentNames.source',
|
||||
verdict: 'pipeline.componentNames.verdict',
|
||||
};
|
||||
|
||||
export const COMPONENT_ORDER = ['ai_tampered', 'source_assessment', 'techniques', 'claims'];
|
||||
|
||||
export const RISK_COLORS: Record<string, string> = {
|
||||
green: '#22c55e',
|
||||
lightgreen: '#84cc16',
|
||||
yellow: '#eab308',
|
||||
orange: '#f97316',
|
||||
red: '#ef4444',
|
||||
darkred: '#dc2626',
|
||||
};
|
||||
|
||||
export const STATUS_ICON: Record<string, string> = {
|
||||
pending: '○',
|
||||
running: '◎',
|
||||
completed: '●',
|
||||
failed: '✗',
|
||||
skipped: '—',
|
||||
};
|
||||
|
||||
export const SEVERITY_BG: Record<string, string> = {
|
||||
critical: 'rgba(239,68,68,0.10)',
|
||||
warning: 'rgba(249,115,22,0.10)',
|
||||
info: 'rgba(59,130,246,0.08)',
|
||||
};
|
||||
export const SEVERITY_BORDER: Record<string, string> = {
|
||||
critical: '#ef4444',
|
||||
warning: '#f97316',
|
||||
info: '#3b82f6',
|
||||
};
|
||||
export const SEVERITY_BG_LIGHT: Record<string, string> = {
|
||||
critical: 'rgba(239,68,68,0.06)',
|
||||
warning: 'rgba(249,115,22,0.06)',
|
||||
info: 'rgba(59,130,246,0.05)',
|
||||
};
|
||||
|
||||
export const formatTechName = (name: string) => {
|
||||
const part = name.includes('.') ? name.split('.').pop()! : name;
|
||||
return part.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
};
|
||||
|
||||
export const formatFactorName = (factor: string) =>
|
||||
factor.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
|
||||
export const getViralityColor = (score: number) =>
|
||||
score >= 75 ? '#dc2626' : score >= 50 ? '#ef4444' : score >= 25 ? '#f97316' : '#22c55e';
|
||||
|
||||
export const getSeverityColor = (s: number) =>
|
||||
s >= 70 ? '#ef4444' : s >= 40 ? '#f97316' : '#eab308';
|
||||
|
||||
export const getProbColor = (p: number) =>
|
||||
p >= 80 ? '#ef4444' : p >= 60 ? '#f97316' : p >= 40 ? '#eab308' : '#22c55e';
|
||||
|
||||
export const getStanceColor = (s: string) =>
|
||||
s === 'SUPPORTS' ? '#22c55e' : s === 'CONTRADICTS' ? '#ef4444' : '#94a3b8';
|
||||
|
||||
export const getStatusColor = (s: string) => {
|
||||
if (s === 'verified_true' || s === 'VT') return '#22c55e';
|
||||
if (s === 'verified_false' || s === 'VF_STATUS') return '#ef4444';
|
||||
return '#94a3b8';
|
||||
};
|
||||
|
||||
export const safeHostname = (url: string): string => {
|
||||
try { return new URL(url).hostname; } catch { return url; }
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue