| |
| |
| |
| |
|
|
| 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); |
| |
| 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())); |
| |
| |
| 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); |
| } |
| }; |
|
|
| 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> |
|
|
| {} |
| <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> |
| ); |
| } |
|
|