analyze / src /App.tsx
wuhp's picture
Upload 14 files
d614256 verified
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useMemo, useRef } from 'react';
import { parseSocialData, ParseResult, getCoreMembers, MemberMetrics, findShortestPath, UserNode, detectClusters, ClusterInfo } from './lib/parser';
import NetworkGraph from './components/NetworkGraph';
type Tab = 'analysis' | 'clusters' | 'filters' | 'logs';
export default function App() {
const [rawData, setRawData] = useState('');
const [analysis, setAnalysis] = useState<ParseResult | null>(null);
const [selectedUser, setSelectedUser] = useState<MemberMetrics | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [minDegree, setMinDegree] = useState(2);
const [pathTargetQuery, setPathTargetQuery] = useState('');
const [highlightedPath, setHighlightedPath] = useState<string[] | null>(null);
const [ignoredUsers, setIgnoredUsers] = useState<string[]>(['carterpcs', 'clavicular']);
const [newIgnoreUser, setNewIgnoreUser] = useState('');
const [linkFilter, setLinkFilter] = useState<'all' | 'following' | 'follower'>('all');
const [activeTab, setActiveTab] = useState<Tab>('analysis');
const fileInputRef = useRef<HTMLInputElement>(null);
const handleAnalyze = () => {
const data = parseSocialData(rawData);
setAnalysis(data);
setSelectedUser(null);
setHighlightedPath(null);
};
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const content = event.target?.result as string;
if (file.name.endsWith('.json')) {
try {
const data = JSON.parse(content);
// Simple validation and reconstruction of Map objects
if (data.nodes && data.relationships) {
const allUsers = new Map<string, UserNode>();
data.nodes.forEach((n: any) => allUsers.set(n.username, n));
const relationships = new Map<string, { following: UserNode[], followers: UserNode[] }>();
Object.entries(data.relationships).forEach(([key, val]: [string, any]) => {
relationships.set(key, {
following: val.following.map((u: string) => allUsers.get(u) || { username: u, displayName: u }),
followers: val.followers.map((u: string) => allUsers.get(u) || { username: u, displayName: u })
});
});
setAnalysis({ allUsers, relationships, logs: [`Imported graph from ${file.name}`] });
}
} catch (err) {
alert("Failed to parse JSON import.");
}
} else {
setRawData(content);
handleAnalyze();
}
};
reader.readAsText(file);
};
const exportJSON = () => {
if (!analysis) return;
const exportData = {
nodes: Array.from(analysis.allUsers.values()),
relationships: Object.fromEntries(
Array.from(analysis.relationships.entries()).map(([k, v]) => [
k,
{ following: v.following.map(x => x.username), followers: v.followers.map(x => x.username) }
])
)
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'network_graph.json';
a.click();
URL.revokeObjectURL(url);
};
const exportCSV = () => {
if (!analysis) return;
let csv = "source,target,type\n";
analysis.relationships.forEach((rels, subject) => {
rels.following.forEach(u => csv += `${subject},${u.username},following\n`);
rels.followers.forEach(u => csv += `${u.username},${subject},follower\n`);
});
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'network_edges.csv';
a.click();
URL.revokeObjectURL(url);
};
const handleFindPath = (target: string) => {
if (!analysis || !selectedUser) return;
const path = findShortestPath(analysis, selectedUser.username, target);
if (path) {
setHighlightedPath(path);
setPathTargetQuery('');
} else {
alert("No path found between these nodes.");
}
};
const filteredAnalysis = useMemo(() => {
if (!analysis) return null;
const ignoredSet = new Set(ignoredUsers.map(u => u.toLowerCase()));
// Deep clone basic structure
const newAllUsers = new Map<string, UserNode>();
analysis.allUsers.forEach((node, username) => {
if (!ignoredSet.has(username.toLowerCase())) {
newAllUsers.set(username, node);
}
});
const newRelationships = new Map<string, { following: UserNode[]; followers: UserNode[] }>();
analysis.relationships.forEach((rels, subject) => {
if (ignoredSet.has(subject.toLowerCase())) return;
const filteredFollowing = rels.following.filter(u => !ignoredSet.has(u.username.toLowerCase()));
const filteredFollowers = rels.followers.filter(u => !ignoredSet.has(u.username.toLowerCase()));
newRelationships.set(subject, {
following: filteredFollowing,
followers: filteredFollowers
});
});
return {
allUsers: newAllUsers,
relationships: newRelationships,
logs: analysis.logs
};
}, [analysis, ignoredUsers]);
const allMembers = useMemo(() => filteredAnalysis ? getCoreMembers(filteredAnalysis) : [], [filteredAnalysis]);
const metricsMap = useMemo(() => new Map(allMembers.map(m => [m.username, m])), [allMembers]);
const clusters = useMemo(() => filteredAnalysis ? detectClusters(filteredAnalysis, allMembers) : [], [filteredAnalysis, allMembers]);
const coreMembers = useMemo(() => allMembers.slice(0, 10), [allMembers]);
const altCandidates = useMemo(() => allMembers.filter(m => m.isAltCandidate), [allMembers]);
const displayedMembers = useMemo(() => {
if (!filteredAnalysis) return [];
if (searchQuery.trim() === '') return coreMembers;
const lowerQ = searchQuery.toLowerCase();
return allMembers.filter(m => m.username.toLowerCase().includes(lowerQ)).slice(0, 20);
}, [allMembers, coreMembers, searchQuery, filteredAnalysis]);
const handleNodeClick = (username: string) => {
if (!filteredAnalysis) return;
const member = allMembers.find(m => m.username === username);
if (member) {
setSelectedUser(member);
setHighlightedPath(null); // Reset path when a new base user is selected
}
};
const addIgnoredUser = (username: string) => {
const trimmed = username.trim().replace(/^@/, '');
if (trimmed && !ignoredUsers.includes(trimmed)) {
setIgnoredUsers([...ignoredUsers, trimmed]);
}
setNewIgnoreUser('');
};
const removeIgnoredUser = (username: string) => {
setIgnoredUsers(ignoredUsers.filter(u => u !== username));
};
return (
<div className="flex flex-col h-screen w-full bg-slate-50 font-sans text-slate-900 overflow-hidden">
{/* Header Navigation */}
<nav className="h-16 bg-white border-b border-slate-200 px-6 flex items-center justify-between shrink-0">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<span className="text-xl font-bold tracking-tight text-slate-800 uppercase">
Relationship <span className="text-indigo-600">Analyzer</span>
</span>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={minDegree > 1}
onChange={e => setMinDegree(e.target.checked ? 2 : 1)}
className="w-4 h-4 text-indigo-600 border-slate-300 rounded focus:ring-indigo-500 cursor-pointer"
/>
<span className="text-xs font-semibold text-slate-600">Hide 1-edge nodes</span>
</label>
<div className="px-3 py-1 bg-slate-100 rounded-full text-xs font-semibold text-slate-500">
{filteredAnalysis ? `${filteredAnalysis.allUsers.size} nodes` : "Ready"}
</div>
<div className="flex bg-slate-100 rounded-lg p-1 border border-slate-200 shadow-inner">
<button
onClick={() => setLinkFilter('all')}
className={`px-3 py-1 text-[10px] font-bold rounded-md transition-all ${linkFilter === 'all' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'}`}
>
ALL
</button>
<button
onClick={() => setLinkFilter('following')}
className={`px-3 py-1 text-[10px] font-bold rounded-md transition-all ${linkFilter === 'following' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'}`}
>
OUT
</button>
<button
onClick={() => setLinkFilter('follower')}
className={`px-3 py-1 text-[10px] font-bold rounded-md transition-all ${linkFilter === 'follower' ? 'bg-white text-green-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'}`}
>
IN
</button>
</div>
<div className="flex bg-indigo-600 rounded-lg overflow-hidden shadow-sm transition hover:bg-indigo-700">
<button onClick={() => fileInputRef.current?.click()} className="px-4 py-2 text-white text-sm font-medium border-r border-indigo-500 hover:bg-indigo-500" title="Import JSON or paste text">
Import
</button>
<input type="file" ref={fileInputRef} onChange={handleImport} accept=".json,.csv,.txt" className="hidden" />
<button onClick={exportJSON} className="px-4 py-2 text-white text-sm font-medium border-r border-indigo-500 hover:bg-indigo-500">
Exp JSON
</button>
<button onClick={exportCSV} className="px-4 py-2 text-white text-sm font-medium hover:bg-indigo-500">
Exp CSV
</button>
</div>
</div>
</nav>
<div className="flex flex-1 overflow-hidden">
{/* Sidebar: Parsing & Data Source */}
<aside className="w-72 bg-white border-r border-slate-200 flex flex-col p-5 shrink-0 overflow-y-auto">
<div className="mb-6">
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-3">Input Source</h3>
<textarea
className="w-full h-48 p-3 border border-slate-200 bg-slate-50 rounded-lg mb-4 font-mono text-[11px] leading-relaxed resize-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none text-slate-600"
placeholder="Paste raw relationship data here..."
value={rawData}
onChange={(e) => setRawData(e.target.value)}
/>
<button
onClick={handleAnalyze}
className="w-full bg-slate-900 text-white px-4 py-3 rounded-lg text-sm font-bold shadow-sm hover:bg-slate-800 transition"
>
Analyze Data
</button>
</div>
<div className="mb-6 h-48 overflow-y-auto">
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-3">Ignore List (OSINT Filter)</h3>
<div className="mb-3 flex gap-2">
<input
type="text"
placeholder="Add user to ignore..."
className="flex-1 text-[10px] p-2 border border-slate-200 rounded bg-slate-50 outline-none focus:ring-1 focus:ring-indigo-500"
value={newIgnoreUser}
onChange={e => setNewIgnoreUser(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addIgnoredUser(newIgnoreUser)}
/>
<button onClick={() => addIgnoredUser(newIgnoreUser)} className="px-2 py-1 bg-slate-200 rounded text-[10px] font-bold text-slate-600 hover:bg-slate-300 transition shrink-0">Add</button>
</div>
<div className="flex flex-wrap gap-1.5">
{ignoredUsers.map(u => (
<div key={u} className="flex items-center gap-1 px-2 py-1 bg-slate-100 border border-slate-200 rounded text-[10px] font-medium text-slate-600 animate-in fade-in zoom-in duration-200">
{u}
<button onClick={() => removeIgnoredUser(u)} className="ml-1 text-slate-400 hover:text-red-500">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
))}
{ignoredUsers.length === 0 && <div className="text-[10px] text-slate-400 italic">No users ignored</div>}
</div>
</div>
<div className="mb-6 h-48 overflow-y-auto">
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-3">Analysis Status</h3>
<div className="flex flex-col gap-2">
{analysis ? (
<>
<div className="text-[11px] font-mono p-2 border-l-2 border-green-500 bg-green-50 text-green-700 tracking-tight">
[OK] Extracted {analysis.allUsers.size} users
</div>
<div className="text-[11px] font-mono p-2 border-l-2 border-indigo-500 bg-indigo-50 text-indigo-700 tracking-tight">
[OK] Identified {analysis.relationships.size} subjects
</div>
{analysis.logs.map((log, idx) => (
<div key={idx} className="text-[10px] font-mono p-2 border-l-2 border-slate-200 text-slate-500 tracking-tight">
{log}
</div>
))}
</>
) : (
<div className="text-[11px] font-mono p-2 border-l-2 border-slate-200 text-slate-400 tracking-tight">
Awaiting input...
</div>
)}
</div>
</div>
{analysis && (
<div className="mt-auto p-4 bg-slate-900 rounded-xl text-white">
<div className="text-xs font-medium text-slate-400 mb-1">Total Unique Nodes</div>
<div className="text-2xl font-bold">{analysis.allUsers.size}</div>
</div>
)}
</aside>
{/* Main Visualization View */}
<main className="flex-1 relative bg-slate-100 flex items-center justify-center p-8 overflow-hidden">
<div className="absolute inset-0 opacity-20" style={{ backgroundImage: 'radial-gradient(#cbd5e1 1px, transparent 1px)', backgroundSize: '24px 24px' }}></div>
<div className="relative w-full h-full border border-slate-200 bg-white rounded-3xl shadow-inner flex flex-col items-center justify-center z-10 p-6 overflow-hidden">
{!filteredAnalysis || filteredAnalysis.allUsers.size === 0 ? (
<div className="text-slate-400 font-medium tracking-wide flex flex-col items-center">
<svg className="w-12 h-12 text-slate-200 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
Paste data to generate visualization
</div>
) : (
<div className="w-full h-full relative">
<NetworkGraph
data={filteredAnalysis}
minDegree={minDegree}
onNodeClick={handleNodeClick}
highlightedNode={selectedUser ? selectedUser.username : null}
metrics={metricsMap}
path={highlightedPath || undefined}
filter={linkFilter}
/>
<div className="absolute top-2 left-6 p-4 bg-white/90 backdrop-blur border border-slate-200 rounded-2xl shadow-xl w-64 z-20 pointer-events-none">
<div className="text-[10px] font-bold text-indigo-600 mb-1 tracking-wider uppercase">Network Summary</div>
<div className="text-xl font-bold mb-1 tracking-tight text-slate-800">
{filteredAnalysis.relationships.size} Centers
</div>
<div className="text-sm text-slate-500 mb-4 whitespace-nowrap truncate">
Mapped from raw data
</div>
<div className="grid grid-cols-2 gap-2 text-[10px] font-bold uppercase text-slate-600">
<div className="p-3 bg-slate-50 rounded-lg border border-slate-100">
<span className="block text-slate-400 mb-1">Nodes</span>
<span className="text-indigo-600 text-lg">{filteredAnalysis.allUsers.size}</span>
</div>
<div className="p-3 bg-slate-50 rounded-lg border border-slate-100">
<span className="block text-slate-400 mb-1">Cores</span>
<span className="text-indigo-600 text-lg">{coreMembers.length}</span>
</div>
</div>
<div className="mt-4 pt-3 border-t border-slate-100 flex flex-col gap-2">
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-blue-500 rounded-full"></div>
<span className="text-[9px] font-bold text-slate-500 uppercase tracking-tight">Outgoing / Following</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-0.5 bg-green-500 rounded-full"></div>
<span className="text-[9px] font-bold text-slate-500 uppercase tracking-tight">Incoming / Follower</span>
</div>
</div>
</div>
{selectedUser && (
<div className="absolute bottom-2 right-6 p-4 bg-indigo-50/90 backdrop-blur border border-indigo-200 rounded-2xl shadow-xl w-64 z-20">
<div className="flex justify-between items-start mb-1">
<div className="text-[10px] font-bold text-indigo-800 tracking-wider uppercase">Selected Node</div>
<button onClick={() => { setSelectedUser(null); setHighlightedPath(null); }} className="text-indigo-400 hover:text-indigo-700">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div className="text-lg font-bold mb-1 text-slate-900 truncate flex items-center justify-between" title={selectedUser.username}>
<span>{selectedUser.username}</span>
<div className="flex gap-1">
<a href={`https://twitter.com/${selectedUser.username}`} target="_blank" rel="noreferrer" className="p-1 bg-white rounded-md border border-indigo-100 hover:bg-indigo-50 transition" title="Search Twitter">
<span className="text-[10px]">𝕏</span>
</a>
<a href={`https://www.tiktok.com/@${selectedUser.username}`} target="_blank" rel="noreferrer" className="p-1 bg-white rounded-md border border-indigo-100 hover:bg-indigo-50 transition" title="Search TikTok">
<span className="text-[10px]">TT</span>
</a>
<a href={`https://www.google.com/search?q=%22${selectedUser.username}%22+social+media`} target="_blank" rel="noreferrer" className="p-1 bg-white rounded-md border border-indigo-100 hover:bg-indigo-50 transition" title="Google Search">
<span className="text-[10px]">G</span>
</a>
</div>
</div>
{highlightedPath ? (
<div className="mt-3 p-3 bg-white rounded-lg border border-indigo-100 max-h-32 overflow-y-auto">
<div className="text-[10px] font-bold uppercase text-slate-400 mb-2">Shortest Path</div>
<div className="flex flex-col gap-1 text-xs">
{highlightedPath.map((u, i) => (
<div key={u} className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-amber-500 text-white flex items-center justify-center text-[8px] font-bold">{i + 1}</div>
<span className="font-semibold text-slate-800 truncate" title={u}>{u}</span>
</div>
))}
</div>
<button onClick={() => setHighlightedPath(null)} className="mt-3 w-full py-1.5 text-[10px] font-bold text-slate-500 bg-slate-50 rounded hover:bg-slate-100 transition">
Clear Path
</button>
</div>
) : (
<>
<div className="grid grid-cols-3 gap-2 mt-3 text-[10px] font-bold uppercase text-slate-600">
<div className="p-2 bg-white rounded-lg border border-indigo-100 flex flex-col items-center">
<span className="block text-slate-400 mb-1">In</span>
<span className="text-indigo-600 text-base">{selectedUser.inDegree}</span>
</div>
<div className="p-2 bg-white rounded-lg border border-indigo-100 flex flex-col items-center">
<span className="block text-slate-400 mb-1">Out</span>
<span className="text-indigo-600 text-base">{selectedUser.outDegree}</span>
</div>
<div className="p-2 bg-white rounded-lg border border-indigo-100 flex flex-col items-center">
<span className="block text-slate-400 mb-1">Mut</span>
<span className="text-indigo-600 text-base">{selectedUser.mutuals}</span>
</div>
</div>
<div className="mt-2 p-2 bg-white rounded-lg border border-indigo-100 flex justify-between items-center text-[10px] font-bold uppercase text-slate-600">
<span className="text-slate-400">Hub Score</span>
<span className="text-indigo-600 text-base">{selectedUser.score}</span>
</div>
{selectedUser.isAltCandidate && (
<div className="mt-3 bg-amber-100 border border-amber-200 text-amber-800 p-2 rounded-lg text-xs font-semibold flex items-center justify-between">
<span>Alt Candidate</span>
<span className="text-[10px] truncate max-w-[80px]" title={selectedUser.altOf}>Of @{selectedUser.altOf}</span>
</div>
)}
<div className="mt-3 pt-3 border-t border-indigo-100">
<div className="text-[10px] font-bold text-indigo-800 mb-2 uppercase">Relation Viewer</div>
<div className="flex gap-2">
<input
type="text"
placeholder="Find path to..."
value={pathTargetQuery}
onChange={e => setPathTargetQuery(e.target.value)}
className="flex-1 text-xs border border-indigo-100 rounded p-1.5 outline-none focus:ring-1 focus:ring-indigo-500"
onKeyDown={e => {
if (e.key === 'Enter' && pathTargetQuery.trim()) {
handleFindPath(pathTargetQuery.trim());
}
}}
/>
<button
onClick={() => pathTargetQuery.trim() && handleFindPath(pathTargetQuery.trim())}
className="bg-indigo-600 text-white rounded px-2 py-1 text-xs font-bold hover:bg-indigo-700 transition"
>
Go
</button>
</div>
</div>
</>
)}
</div>
)}
</div>
)}
</div>
</main>
{/* Right Panel: Insights */}
<section className="w-80 bg-white border-l border-slate-200 flex flex-col shrink-0 overflow-hidden">
<div className="flex border-b border-slate-100 bg-slate-50/50 p-1">
{(['analysis', 'clusters', 'filters', 'logs'] as Tab[]).map(t => (
<button
key={t}
onClick={() => setActiveTab(t)}
className={`flex-1 py-2 text-[10px] font-bold uppercase tracking-wider rounded-md transition-all ${activeTab === t ? 'bg-white text-indigo-600 shadow-sm border border-slate-200' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-100/50'}`}
>
{t}
</button>
))}
</div>
<div className="p-5 flex-1 overflow-y-auto">
{activeTab === 'analysis' && (
<>
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">Core Network Members</h3>
<div className="mb-4">
<input
type="text"
placeholder="Search members..."
className="w-full text-xs p-2 border border-slate-200 rounded-lg outline-none focus:ring-2 focus:ring-indigo-500 bg-slate-50"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
</div>
{filteredAnalysis && displayedMembers.length > 0 ? (
<ul className="space-y-3">
{displayedMembers.map((m, i) => {
const isHub = i === 0 && searchQuery === '';
const isBridge = i > 0 && i < 4 && searchQuery === '';
const role = isHub ? 'HUB' : (isBridge ? 'BRIDGE' : 'NODE');
return (
<li key={m.username} onClick={() => handleNodeClick(m.username)} className={`flex items-center gap-3 group cursor-pointer p-1 -mx-1 rounded-lg hover:bg-slate-50 transition ${selectedUser?.username === m.username ? 'bg-indigo-50/50 ring-1 ring-indigo-100' : ''}`}>
<div className={`w-7 h-7 rounded-sm flex items-center justify-center text-[10px] font-bold shrink-0 transition-colors
${selectedUser?.username === m.username ? 'bg-indigo-600 text-white shadow-md' : 'bg-slate-100 text-slate-500 group-hover:bg-slate-200'}`}>
{(i + 1).toString().padStart(2, '0')}
</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-semibold text-slate-800 truncate flex items-center gap-1" title={m.username}>
{m.username}
{m.isAltCandidate && <span className="text-[10px]" title="Alt Account">⚠️</span>}
</div>
<div className="flex items-center">
<span className="text-[10px] text-slate-400">{m.inDegree + m.outDegree} edges</span>
</div>
</div>
{searchQuery === '' && (
<span className={`text-[9px] font-mono font-bold px-1.5 py-0.5 rounded
${isHub ? 'bg-indigo-100 text-indigo-700' :
isBridge ? 'bg-slate-100 text-slate-600' : 'bg-transparent text-slate-400 border border-slate-200'}`}
>
{role}
</span>
)}
</li>
);
})}
</ul>
) : (
<div className="w-full h-32 border-2 border-dashed border-slate-100 rounded-xl flex items-center justify-center">
<span className="text-xs font-medium text-slate-400">No members analyzed</span>
</div>
)}
{altCandidates.length > 0 && searchQuery === '' && (
<div className="mt-8 mb-4">
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">Alt / Dup Suspects</h3>
<div className="space-y-3">
{altCandidates.slice(0, 8).map(m => (
<div key={m.username} onClick={() => handleNodeClick(m.username)} className="p-3 bg-amber-50 rounded-xl border border-amber-100 cursor-pointer hover:bg-amber-100 transition">
<div className="flex justify-between items-start mb-2">
<div className="min-w-0 flex-1">
<div className="text-xs font-bold text-slate-800 truncate" title={m.username}>{m.username}</div>
</div>
<div className="bg-amber-100 text-amber-700 text-[9px] font-bold px-1.5 py-0.5 rounded ml-2 shrink-0">
Likely Alt
</div>
</div>
<div className="text-[10px] text-slate-500 mt-2 border-t border-amber-100 pt-2">
Similar to <span className="font-bold text-slate-800">@{m.altOf}</span>
</div>
</div>
))}
</div>
</div>
)}
</>
)}
{activeTab === 'clusters' && (
<>
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">Network Clusters</h3>
<div className="space-y-4">
{clusters.length > 0 ? clusters.map(c => (
<div key={c.center} className="p-3 bg-white border border-slate-200 rounded-xl shadow-sm hover:shadow-md transition">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: c.color }}></div>
<span className="text-xs font-bold text-slate-800 truncate">{c.center}'s Orbit</span>
</div>
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">{c.members.length} members</span>
</div>
<div className="flex flex-wrap gap-1 max-h-24 overflow-y-auto">
{c.members.slice(0, 15).map(m => (
<span
key={m}
onClick={() => handleNodeClick(m)}
className="text-[9px] px-1.5 py-0.5 bg-slate-100 text-slate-600 rounded-md border border-slate-200 hover:bg-indigo-50 hover:text-indigo-600 cursor-pointer transition"
>
{m}
</span>
))}
{c.members.length > 15 && <span className="text-[9px] text-slate-400">+{c.members.length - 15} more</span>}
</div>
</div>
)) : (
<div className="text-center py-8 text-slate-400 italic text-xs">
No significant clusters detected. Try analyzing more subjects.
</div>
)}
</div>
</>
)}
{activeTab === 'filters' && (
<>
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-3">Ignore List (OSINT Filter)</h3>
<div className="mb-3 flex gap-2">
<input
type="text"
placeholder="Add user to ignore..."
className="flex-1 text-[10px] p-2 border border-slate-200 rounded bg-slate-50 outline-none focus:ring-1 focus:ring-indigo-500"
value={newIgnoreUser}
onChange={e => setNewIgnoreUser(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addIgnoredUser(newIgnoreUser)}
/>
<button onClick={() => addIgnoredUser(newIgnoreUser)} className="px-2 py-1 bg-slate-200 rounded text-[10px] font-bold text-slate-600 hover:bg-slate-300 transition shrink-0">Add</button>
</div>
<div className="flex flex-wrap gap-1.5 mb-8">
{ignoredUsers.map(u => (
<div key={u} className="flex items-center gap-1 px-2 py-1 bg-slate-100 border border-slate-200 rounded text-[10px] font-medium text-slate-600 animate-in fade-in zoom-in duration-200">
{u}
<button onClick={() => removeIgnoredUser(u)} className="ml-1 text-slate-400 hover:text-red-500">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
))}
{ignoredUsers.length === 0 && <div className="text-[10px] text-slate-400 italic">No users ignored</div>}
</div>
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-3">Graph Display</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-[11px] font-medium text-slate-600">Minimum connections</span>
<div className="flex gap-2">
{[1, 2, 3, 5].map(v => (
<button
key={v}
onClick={() => setMinDegree(v)}
className={`w-7 h-7 rounded text-[10px] font-bold transition ${minDegree === v ? 'bg-indigo-600 text-white shadow-sm' : 'bg-slate-100 text-slate-400 hover:bg-slate-200'}`}
>
{v}+
</button>
))}
</div>
</div>
<div className="p-3 bg-slate-50 border border-slate-200 rounded-lg text-[10px] text-slate-500 leading-relaxed italic">
Increasing the minimum connection requirement helps clean up the graph by removing users only tied to one subject.
</div>
</div>
</>
)}
{activeTab === 'logs' && (
<>
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">Parser Logs</h3>
<div className="space-y-1">
{filteredAnalysis?.logs.map((log, idx) => (
<div key={idx} className="text-[9px] font-mono p-2 border-l-2 border-slate-200 bg-slate-50 text-slate-500 tracking-tight break-all">
{log}
</div>
))}
{!filteredAnalysis && (
<div className="text-center py-8 text-slate-400 italic text-xs font-mono">
No logs available.
</div>
)}
</div>
</>
)}
</div>
{analysis && (
<div className="p-5 border-t border-slate-100 flex gap-3 shrink-0 bg-slate-50">
<button onClick={() => { setSelectedUser(null); setHighlightedPath(null); }} className="flex-1 py-2.5 text-xs font-bold text-slate-600 bg-white border border-slate-200 shadow-sm rounded-lg hover:bg-slate-50 transition cursor-pointer">
Clear Select
</button>
<button onClick={exportJSON} className="flex-1 py-2.5 text-xs font-bold text-white bg-slate-900 border border-slate-900 shadow-sm rounded-lg hover:bg-slate-800 transition cursor-pointer">
Save JS
</button>
</div>
)}
</section>
</div>
</div>
);
}