first commit

This commit is contained in:
2026-01-25 23:05:41 +02:00
commit dec7844b49
48 changed files with 10815 additions and 0 deletions

18
frontend/index.html Normal file
View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Private OSINT Automation Platform" />
<meta name="theme-color" content="#020617" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
<title>OSINT Platform</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2848
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
frontend/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "osint-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.3",
"socket.io-client": "^4.7.5",
"lucide-react": "^0.359.0",
"zustand": "^4.5.2",
"date-fns": "^3.6.0"
},
"devDependencies": {
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.4.2",
"vite": "^5.1.6"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

6
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="6" fill="#020617"/>
<path d="M16 6L7 11v10l9 5 9-5V11l-9-5z" stroke="#22d3ee" stroke-width="1.5" fill="none"/>
<circle cx="16" cy="16" r="4" fill="#22d3ee" opacity="0.3"/>
<path d="M16 12v8M12 16h8" stroke="#22d3ee" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 387 B

36
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,36 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from './stores/authStore';
import { LoginPage } from './pages/LoginPage';
import { Dashboard } from './pages/Dashboard';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
function App() {
return (
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<div className="min-h-screen bg-slate-950">
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/*"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
</Routes>
</div>
</BrowserRouter>
);
}
export default App;

View File

@@ -0,0 +1,161 @@
import { useState } from 'react';
import { useTargetStore } from '../stores/targetStore';
import { X, Globe, Twitter, Instagram, Linkedin, Facebook } from 'lucide-react';
interface AddProfileModalProps {
onClose: () => void;
}
const PLATFORMS = [
{ id: 'x', name: 'X (Twitter)', icon: Twitter },
{ id: 'instagram', name: 'Instagram', icon: Instagram },
{ id: 'linkedin', name: 'LinkedIn', icon: Linkedin },
{ id: 'facebook', name: 'Facebook', icon: Facebook },
{ id: 'other', name: 'Other', icon: Globe },
];
export function AddProfileModal({ onClose }: AddProfileModalProps) {
const { selectedTarget, addProfile } = useTargetStore();
const [platform, setPlatform] = useState('x');
const [username, setUsername] = useState('');
const [profileUrl, setProfileUrl] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
if (!selectedTarget) {
return null;
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!platform) return;
setLoading(true);
setError('');
try {
await addProfile(
selectedTarget.id,
platform,
username.trim() || undefined,
profileUrl.trim() || undefined
);
onClose();
} catch (err: any) {
setError(err.message || 'Failed to add profile');
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative w-full max-w-md mx-4 bg-slate-900 border border-slate-800 rounded-xl shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-800">
<div className="flex items-center gap-2">
<Globe className="w-5 h-5 text-accent-cyan" />
<div>
<h2 className="text-lg font-semibold text-slate-100">Add Profile</h2>
<p className="text-xs text-slate-500">for {selectedTarget.name}</p>
</div>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-200 hover:bg-slate-800 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-4 space-y-4">
{error && (
<div className="p-3 rounded-lg bg-accent-rose/10 border border-accent-rose/20 text-accent-rose text-sm">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-400 mb-2">
Platform *
</label>
<div className="grid grid-cols-5 gap-2">
{PLATFORMS.map((p) => {
const Icon = p.icon;
return (
<button
key={p.id}
type="button"
onClick={() => setPlatform(p.id)}
className={`flex flex-col items-center gap-1 p-3 rounded-lg border transition-colors ${
platform === p.id
? 'border-accent-cyan bg-accent-cyan/10 text-accent-cyan'
: 'border-slate-700 text-slate-400 hover:border-slate-600 hover:text-slate-300'
}`}
>
<Icon className="w-5 h-5" />
<span className="text-[10px]">{p.id === 'x' ? 'X' : p.id.slice(0, 2).toUpperCase()}</span>
</button>
);
})}
</div>
</div>
<div>
<label htmlFor="username" className="block text-sm font-medium text-slate-400 mb-2">
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="input-field w-full font-mono"
placeholder="@username"
/>
</div>
<div>
<label htmlFor="profileUrl" className="block text-sm font-medium text-slate-400 mb-2">
Profile URL
</label>
<input
id="profileUrl"
type="url"
value={profileUrl}
onChange={(e) => setProfileUrl(e.target.value)}
className="input-field w-full font-mono text-sm"
placeholder="https://..."
/>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="btn-secondary flex-1"
disabled={loading}
>
Cancel
</button>
<button
type="submit"
className="btn-primary flex-1"
disabled={!platform || loading}
>
{loading ? 'Adding...' : 'Add Profile'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,190 @@
import { useState } from 'react';
import { useScraperStore } from '../stores/scraperStore';
import { X, Key, AlertCircle, HelpCircle } from 'lucide-react';
interface AddSessionModalProps {
onClose: () => void;
}
const PLATFORMS = [
{ id: 'x', name: 'X (Twitter)' },
{ id: 'instagram', name: 'Instagram' },
{ id: 'linkedin', name: 'LinkedIn' },
{ id: 'facebook', name: 'Facebook' },
];
export function AddSessionModal({ onClose }: AddSessionModalProps) {
const { addSession } = useScraperStore();
const [platform, setPlatform] = useState('x');
const [sessionName, setSessionName] = useState('');
const [cookiesJson, setCookiesJson] = useState('');
const [userAgent, setUserAgent] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!platform || !sessionName.trim() || !cookiesJson.trim()) {
setError('Please fill in all required fields');
return;
}
// Parse cookies JSON
let cookies: any[];
try {
cookies = JSON.parse(cookiesJson);
if (!Array.isArray(cookies)) {
throw new Error('Cookies must be an array');
}
} catch (err) {
setError('Invalid cookies JSON. Please provide a valid array of cookie objects.');
return;
}
setLoading(true);
setError('');
try {
await addSession({
platform,
session_name: sessionName.trim(),
cookies,
user_agent: userAgent.trim() || undefined,
});
onClose();
} catch (err: any) {
setError(err.message || 'Failed to add session');
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative w-full max-w-lg mx-4 bg-slate-900 border border-slate-800 rounded-xl shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-800">
<div className="flex items-center gap-2">
<Key className="w-5 h-5 text-accent-cyan" />
<h2 className="text-lg font-semibold text-slate-100">Add Session</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-200 hover:bg-slate-800 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-4 space-y-4">
{error && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-accent-rose/10 border border-accent-rose/20 text-accent-rose text-sm">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{error}</span>
</div>
)}
{/* Info box */}
<div className="flex items-start gap-2 p-3 rounded-lg bg-accent-cyan/10 border border-accent-cyan/20 text-accent-cyan text-xs">
<HelpCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium mb-1">How to get cookies:</p>
<ol className="list-decimal list-inside text-accent-cyan/80 space-y-0.5">
<li>Log into the platform in your browser</li>
<li>Open DevTools (F12) Application Cookies</li>
<li>Export cookies as JSON array</li>
<li>Or use a browser extension like "Cookie Editor"</li>
</ol>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="platform" className="block text-sm font-medium text-slate-400 mb-2">
Platform *
</label>
<select
id="platform"
value={platform}
onChange={(e) => setPlatform(e.target.value)}
className="input-field w-full"
>
{PLATFORMS.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
<div>
<label htmlFor="sessionName" className="block text-sm font-medium text-slate-400 mb-2">
Session Name *
</label>
<input
id="sessionName"
type="text"
value={sessionName}
onChange={(e) => setSessionName(e.target.value)}
className="input-field w-full"
placeholder="My Account"
/>
</div>
</div>
<div>
<label htmlFor="cookies" className="block text-sm font-medium text-slate-400 mb-2">
Cookies JSON *
</label>
<textarea
id="cookies"
value={cookiesJson}
onChange={(e) => setCookiesJson(e.target.value)}
className="input-field w-full h-32 resize-none font-mono text-xs"
placeholder='[{"name": "auth_token", "value": "...", "domain": ".twitter.com"}]'
/>
</div>
<div>
<label htmlFor="userAgent" className="block text-sm font-medium text-slate-400 mb-2">
User Agent <span className="text-slate-600">(optional)</span>
</label>
<input
id="userAgent"
type="text"
value={userAgent}
onChange={(e) => setUserAgent(e.target.value)}
className="input-field w-full font-mono text-xs"
placeholder="Mozilla/5.0 ..."
/>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="btn-secondary flex-1"
disabled={loading}
>
Cancel
</button>
<button
type="submit"
className="btn-primary flex-1"
disabled={!platform || !sessionName.trim() || !cookiesJson.trim() || loading}
>
{loading ? 'Saving...' : 'Save Session'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import { useState } from 'react';
import { useTargetStore } from '../stores/targetStore';
import { X, UserPlus } from 'lucide-react';
interface AddTargetModalProps {
onClose: () => void;
}
export function AddTargetModal({ onClose }: AddTargetModalProps) {
const { createTarget } = useTargetStore();
const [name, setName] = useState('');
const [notes, setNotes] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
setLoading(true);
setError('');
try {
await createTarget(name.trim(), notes.trim() || undefined);
onClose();
} catch (err: any) {
setError(err.message || 'Failed to create target');
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative w-full max-w-md mx-4 bg-slate-900 border border-slate-800 rounded-xl shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-800">
<div className="flex items-center gap-2">
<UserPlus className="w-5 h-5 text-accent-cyan" />
<h2 className="text-lg font-semibold text-slate-100">New Target</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-200 hover:bg-slate-800 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-4 space-y-4">
{error && (
<div className="p-3 rounded-lg bg-accent-rose/10 border border-accent-rose/20 text-accent-rose text-sm">
{error}
</div>
)}
<div>
<label htmlFor="name" className="block text-sm font-medium text-slate-400 mb-2">
Target Name *
</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="input-field w-full"
placeholder="e.g., John Doe"
autoFocus
/>
</div>
<div>
<label htmlFor="notes" className="block text-sm font-medium text-slate-400 mb-2">
Notes
</label>
<textarea
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="input-field w-full h-24 resize-none"
placeholder="Additional information about this target..."
/>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="btn-secondary flex-1"
disabled={loading}
>
Cancel
</button>
<button
type="submit"
className="btn-primary flex-1"
disabled={!name.trim() || loading}
>
{loading ? 'Creating...' : 'Create Target'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { Shield, LogOut, Plus, Key, Activity } from 'lucide-react';
import { useAuthStore } from '../stores/authStore';
import { useScraperStore } from '../stores/scraperStore';
import { useNavigate } from 'react-router-dom';
interface HeaderProps {
onAddTarget: () => void;
onAddSession: () => void;
}
export function Header({ onAddTarget, onAddSession }: HeaderProps) {
const { logout } = useAuthStore();
const { activeJobs, sessions } = useScraperStore();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/login');
};
const runningJobs = activeJobs.filter(j => j.status === 'running').length;
const activeSessions = sessions.filter(s => s.status === 'active').length;
return (
<header className="h-14 flex-shrink-0 bg-slate-900 border-b border-slate-800 px-4 flex items-center justify-between">
{/* Logo */}
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-accent-cyan/20 to-accent-violet/20 border border-accent-cyan/30">
<Shield className="w-4 h-4 text-accent-cyan" />
</div>
<div>
<h1 className="text-sm font-semibold text-slate-100">OSINT Platform</h1>
<p className="text-[10px] text-slate-500 font-mono uppercase tracking-wider">Intelligence Automation</p>
</div>
</div>
{/* Status indicators */}
<div className="flex items-center gap-4">
{/* Running jobs indicator */}
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-800/50 border border-slate-700/50">
<Activity className={`w-3.5 h-3.5 ${runningJobs > 0 ? 'text-accent-emerald animate-pulse' : 'text-slate-500'}`} />
<span className="text-xs font-medium text-slate-400">
{runningJobs > 0 ? `${runningJobs} Active` : 'Idle'}
</span>
</div>
{/* Sessions indicator */}
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-800/50 border border-slate-700/50">
<Key className={`w-3.5 h-3.5 ${activeSessions > 0 ? 'text-accent-cyan' : 'text-slate-500'}`} />
<span className="text-xs font-medium text-slate-400">
{activeSessions} Sessions
</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<button
onClick={onAddSession}
className="btn-secondary flex items-center gap-1.5 text-xs py-1.5"
>
<Key className="w-3.5 h-3.5" />
<span>Add Session</span>
</button>
<button
onClick={onAddTarget}
className="btn-primary flex items-center gap-1.5 text-xs py-1.5"
>
<Plus className="w-3.5 h-3.5" />
<span>New Target</span>
</button>
<div className="w-px h-6 bg-slate-700 mx-2" />
<button
onClick={handleLogout}
className="p-2 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-800 transition-colors"
title="Logout"
>
<LogOut className="w-4 h-4" />
</button>
</div>
</header>
);
}

View File

@@ -0,0 +1,238 @@
import { useState } from 'react';
import { useTargetStore } from '../stores/targetStore';
import { useScraperStore } from '../stores/scraperStore';
import {
Globe, Twitter, Instagram, Linkedin, Facebook,
ExternalLink, Play, Trash2, ChevronDown, ChevronUp,
Database, FileJson, Copy, CheckCircle, AlertCircle
} from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
const PLATFORM_ICONS: Record<string, any> = {
twitter: Twitter,
x: Twitter,
instagram: Instagram,
linkedin: Linkedin,
facebook: Facebook,
default: Globe,
};
const PLATFORM_COLORS: Record<string, string> = {
twitter: 'text-sky-400',
x: 'text-slate-300',
instagram: 'text-pink-400',
linkedin: 'text-blue-400',
facebook: 'text-blue-500',
default: 'text-slate-400',
};
export function MainDataFeed() {
const { selectedTarget, deleteProfile } = useTargetStore();
const { startJob } = useScraperStore();
const [expandedProfile, setExpandedProfile] = useState<string | null>(null);
const [scraping, setScraping] = useState<string | null>(null);
const [copied, setCopied] = useState<string | null>(null);
if (!selectedTarget) {
return (
<div className="h-full flex items-center justify-center bg-slate-950">
<div className="text-center">
<Database className="w-16 h-16 text-slate-800 mx-auto mb-4" />
<h2 className="text-lg font-medium text-slate-400 mb-2">No Target Selected</h2>
<p className="text-sm text-slate-600 max-w-xs">
Select a target from the left panel to view their social media profiles and scraped data.
</p>
</div>
</div>
);
}
const profiles = selectedTarget.profiles || [];
const handleScrape = async (profileId: string, platform: string, profileUrl?: string) => {
setScraping(profileId);
try {
await startJob({
platform,
profileUrl,
targetId: selectedTarget.id,
profileId,
});
} catch (err) {
console.error('Failed to start scrape:', err);
} finally {
setScraping(null);
}
};
const handleDelete = async (profileId: string) => {
if (confirm('Delete this profile?')) {
await deleteProfile(selectedTarget.id, profileId);
}
};
const copyData = (data: any, id: string) => {
navigator.clipboard.writeText(JSON.stringify(data, null, 2));
setCopied(id);
setTimeout(() => setCopied(null), 2000);
};
return (
<div className="h-full flex flex-col bg-slate-950">
{/* Header */}
<div className="p-4 border-b border-slate-800">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-100">{selectedTarget.name}</h2>
<p className="text-xs text-slate-500 font-mono mt-0.5">
ID: {selectedTarget.id.slice(0, 8)}...
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">
{profiles.length} profile{profiles.length !== 1 ? 's' : ''}
</span>
</div>
</div>
{selectedTarget.notes && (
<p className="text-sm text-slate-400 mt-3 p-3 bg-slate-900 rounded border border-slate-800">
{selectedTarget.notes}
</p>
)}
</div>
{/* Profiles */}
<div className="flex-1 overflow-y-auto p-4">
{profiles.length === 0 ? (
<div className="text-center py-12">
<Globe className="w-12 h-12 text-slate-700 mx-auto mb-3" />
<p className="text-sm text-slate-500">No profiles linked</p>
<p className="text-xs text-slate-600 mt-1">Add social media profiles to start collecting data</p>
</div>
) : (
<div className="space-y-3">
{profiles.map(profile => {
const Icon = PLATFORM_ICONS[profile.platform.toLowerCase()] || PLATFORM_ICONS.default;
const colorClass = PLATFORM_COLORS[profile.platform.toLowerCase()] || PLATFORM_COLORS.default;
const isExpanded = expandedProfile === profile.id;
const profileData = profile.profile_data ? JSON.parse(profile.profile_data) : null;
return (
<div key={profile.id} className="card border-slate-800">
{/* Profile header */}
<div className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg bg-slate-800 ${colorClass}`}>
<Icon className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-slate-200">
{profile.username || profile.platform}
</span>
<span className="text-xs text-slate-600 font-mono uppercase">
{profile.platform}
</span>
</div>
{profile.profile_url && (
<a
href={profile.profile_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-slate-500 hover:text-accent-cyan flex items-center gap-1 mt-0.5"
>
<span className="truncate max-w-[200px]">{profile.profile_url}</span>
<ExternalLink className="w-3 h-3" />
</a>
)}
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handleScrape(profile.id, profile.platform, profile.profile_url || undefined)}
disabled={scraping === profile.id}
className="p-2 rounded hover:bg-slate-800 text-accent-cyan transition-colors disabled:opacity-50"
title="Start scrape"
>
{scraping === profile.id ? (
<div className="w-4 h-4 border-2 border-accent-cyan border-t-transparent rounded-full animate-spin" />
) : (
<Play className="w-4 h-4" />
)}
</button>
<button
onClick={() => handleDelete(profile.id)}
className="p-2 rounded hover:bg-slate-800 text-slate-500 hover:text-accent-rose transition-colors"
title="Delete profile"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* Scraped status */}
<div className="flex items-center gap-4 mt-3 pt-3 border-t border-slate-800">
{profile.last_scraped ? (
<div className="flex items-center gap-1.5 text-xs text-accent-emerald">
<CheckCircle className="w-3.5 h-3.5" />
<span>Last scraped {formatDistanceToNow(new Date(profile.last_scraped), { addSuffix: true })}</span>
</div>
) : (
<div className="flex items-center gap-1.5 text-xs text-slate-500">
<AlertCircle className="w-3.5 h-3.5" />
<span>Never scraped</span>
</div>
)}
{profileData && (
<button
onClick={() => setExpandedProfile(isExpanded ? null : profile.id)}
className="flex items-center gap-1 text-xs text-slate-400 hover:text-slate-200"
>
<FileJson className="w-3.5 h-3.5" />
<span>View Data</span>
{isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</button>
)}
</div>
</div>
{/* Expanded data view */}
{isExpanded && profileData && (
<div className="border-t border-slate-800 p-4 bg-slate-900/50">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-medium text-slate-400">Scraped Data</span>
<button
onClick={() => copyData(profileData, profile.id)}
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-300"
>
{copied === profile.id ? (
<>
<CheckCircle className="w-3.5 h-3.5 text-accent-emerald" />
<span className="text-accent-emerald">Copied!</span>
</>
) : (
<>
<Copy className="w-3.5 h-3.5" />
<span>Copy JSON</span>
</>
)}
</button>
</div>
<pre className="text-xs text-slate-300 font-mono bg-slate-950 p-3 rounded overflow-x-auto max-h-64 overflow-y-auto">
{JSON.stringify(profileData, null, 2)}
</pre>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,288 @@
import { useState } from 'react';
import { useScraperStore, ScraperJob, Session } from '../stores/scraperStore';
import {
Activity, Key, Clock, CheckCircle, XCircle,
StopCircle, ChevronDown, Terminal, RefreshCw
} from 'lucide-react';
import { formatDistanceToNow, format } from 'date-fns';
type Tab = 'jobs' | 'sessions';
export function ScraperPanel() {
const [activeTab, setActiveTab] = useState<Tab>('jobs');
const { jobs, sessions, activeJobs, fetchJobs, fetchSessions } = useScraperStore();
return (
<div className="h-full flex flex-col bg-slate-900/50">
{/* Tabs */}
<div className="flex border-b border-slate-800">
<button
onClick={() => setActiveTab('jobs')}
className={`flex-1 px-4 py-3 text-xs font-medium flex items-center justify-center gap-2 transition-colors ${
activeTab === 'jobs'
? 'text-accent-cyan border-b-2 border-accent-cyan bg-slate-800/30'
: 'text-slate-400 hover:text-slate-200'
}`}
>
<Activity className="w-3.5 h-3.5" />
Jobs
{activeJobs.length > 0 && (
<span className="px-1.5 py-0.5 rounded-full bg-accent-cyan/20 text-accent-cyan text-[10px]">
{activeJobs.length}
</span>
)}
</button>
<button
onClick={() => setActiveTab('sessions')}
className={`flex-1 px-4 py-3 text-xs font-medium flex items-center justify-center gap-2 transition-colors ${
activeTab === 'sessions'
? 'text-accent-cyan border-b-2 border-accent-cyan bg-slate-800/30'
: 'text-slate-400 hover:text-slate-200'
}`}
>
<Key className="w-3.5 h-3.5" />
Sessions
<span className="px-1.5 py-0.5 rounded-full bg-slate-700 text-slate-300 text-[10px]">
{sessions.length}
</span>
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{activeTab === 'jobs' ? (
<JobsList jobs={jobs} onRefresh={fetchJobs} />
) : (
<SessionsList sessions={sessions} onRefresh={fetchSessions} />
)}
</div>
</div>
);
}
function JobsList({ jobs, onRefresh }: { jobs: ScraperJob[]; onRefresh: () => void }) {
const { cancelJob, logs } = useScraperStore();
const [expandedJob, setExpandedJob] = useState<string | null>(null);
const getStatusIcon = (status: string) => {
switch (status) {
case 'running':
return <div className="w-3 h-3 border-2 border-accent-cyan border-t-transparent rounded-full animate-spin" />;
case 'completed':
return <CheckCircle className="w-4 h-4 text-accent-emerald" />;
case 'failed':
return <XCircle className="w-4 h-4 text-accent-rose" />;
case 'cancelled':
return <StopCircle className="w-4 h-4 text-slate-500" />;
default:
return <Clock className="w-4 h-4 text-accent-amber" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'running': return 'border-accent-cyan/30 bg-accent-cyan/5';
case 'completed': return 'border-accent-emerald/30 bg-accent-emerald/5';
case 'failed': return 'border-accent-rose/30 bg-accent-rose/5';
default: return 'border-slate-700 bg-slate-800/50';
}
};
return (
<div className="p-3 space-y-2">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-slate-500">{jobs.length} total jobs</span>
<button
onClick={onRefresh}
className="p-1.5 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-800"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
{jobs.length === 0 ? (
<div className="text-center py-8">
<Activity className="w-10 h-10 text-slate-700 mx-auto mb-2" />
<p className="text-xs text-slate-500">No scraper jobs yet</p>
</div>
) : (
jobs.slice(0, 20).map(job => {
const isExpanded = expandedJob === job.id;
const jobLogs = logs.get(job.id) || [];
return (
<div
key={job.id}
className={`rounded-lg border ${getStatusColor(job.status)} transition-colors`}
>
<button
onClick={() => setExpandedJob(isExpanded ? null : job.id)}
className="w-full p-3 text-left"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{getStatusIcon(job.status)}
<span className="text-sm font-medium text-slate-200 capitalize">
{job.platform}
</span>
</div>
<ChevronDown className={`w-4 h-4 text-slate-500 transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
</div>
<div className="flex items-center gap-3 mt-2">
{job.status === 'running' && (
<div className="flex-1">
<div className="h-1 bg-slate-700 rounded-full overflow-hidden">
<div
className="h-full bg-accent-cyan transition-all"
style={{ width: `${job.progress}%` }}
/>
</div>
</div>
)}
<span className="text-[10px] text-slate-500 font-mono">
{formatDistanceToNow(new Date(job.created_at), { addSuffix: true })}
</span>
</div>
</button>
{isExpanded && (
<div className="px-3 pb-3 border-t border-slate-700/50 mt-2 pt-2">
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-slate-500">Job ID:</span>
<span className="text-slate-300 font-mono">{job.id.slice(0, 8)}...</span>
</div>
{job.target_name && (
<div className="flex justify-between">
<span className="text-slate-500">Target:</span>
<span className="text-slate-300">{job.target_name}</span>
</div>
)}
{job.error && (
<div className="mt-2 p-2 bg-accent-rose/10 rounded text-accent-rose text-xs">
{job.error}
</div>
)}
{job.status === 'running' && (
<button
onClick={() => cancelJob(job.id)}
className="w-full mt-2 py-1.5 rounded border border-accent-rose/30 text-accent-rose text-xs hover:bg-accent-rose/10"
>
Cancel Job
</button>
)}
{/* Logs */}
{jobLogs.length > 0 && (
<div className="mt-3">
<div className="flex items-center gap-1 text-slate-500 mb-2">
<Terminal className="w-3 h-3" />
<span>Logs</span>
</div>
<div className="bg-slate-950 rounded p-2 max-h-32 overflow-y-auto font-mono text-[10px] space-y-0.5">
{jobLogs.slice(-10).map((log, i) => (
<div key={i} className={`${log.level === 'error' ? 'text-accent-rose' : 'text-slate-400'}`}>
<span className="text-slate-600">[{format(new Date(log.timestamp), 'HH:mm:ss')}]</span>{' '}
{log.message}
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
</div>
);
})
)}
</div>
);
}
function SessionsList({ sessions, onRefresh }: { sessions: Session[]; onRefresh: () => void }) {
const { deleteSession } = useScraperStore();
const getStatusIndicator = (status: string) => {
switch (status) {
case 'active':
return <div className="status-indicator status-active" />;
case 'expired':
return <div className="status-indicator status-warning" />;
default:
return <div className="status-indicator status-error" />;
}
};
return (
<div className="p-3 space-y-2">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-slate-500">Session Vault</span>
<button
onClick={onRefresh}
className="p-1.5 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-800"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
{sessions.length === 0 ? (
<div className="text-center py-8">
<Key className="w-10 h-10 text-slate-700 mx-auto mb-2" />
<p className="text-xs text-slate-500">No sessions stored</p>
<p className="text-[10px] text-slate-600 mt-1">Add session cookies to enable authenticated scraping</p>
</div>
) : (
sessions.map(session => (
<div key={session.id} className="p-3 rounded-lg border border-slate-800 bg-slate-800/30">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{getStatusIndicator(session.status)}
<span className="text-sm font-medium text-slate-200 capitalize">
{session.platform}
</span>
</div>
<span className={`text-[10px] px-2 py-0.5 rounded-full ${
session.status === 'active'
? 'bg-accent-emerald/20 text-accent-emerald'
: session.status === 'expired'
? 'bg-accent-amber/20 text-accent-amber'
: 'bg-accent-rose/20 text-accent-rose'
}`}>
{session.status}
</span>
</div>
<div className="mt-2 space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-slate-500">Name:</span>
<span className="text-slate-300">{session.session_name}</span>
</div>
{session.last_validated && (
<div className="flex justify-between">
<span className="text-slate-500">Validated:</span>
<span className="text-slate-400 font-mono text-[10px]">
{formatDistanceToNow(new Date(session.last_validated), { addSuffix: true })}
</span>
</div>
)}
</div>
<button
onClick={() => {
if (confirm(`Delete ${session.platform} session?`)) {
deleteSession(session.id);
}
}}
className="w-full mt-3 py-1.5 rounded border border-slate-700 text-slate-400 text-xs hover:border-accent-rose/30 hover:text-accent-rose hover:bg-accent-rose/5 transition-colors"
>
Remove Session
</button>
</div>
))
)}
</div>
);
}

View File

@@ -0,0 +1,199 @@
import { useState } from 'react';
import { useTargetStore, Target } from '../stores/targetStore';
import {
Users, Search, ChevronRight, Plus, MoreHorizontal,
Trash2, Edit2, UserCircle2
} from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
interface TargetListProps {
onAddProfile: () => void;
}
export function TargetList({ onAddProfile }: TargetListProps) {
const { targets, selectedTarget, selectTarget, fetchTarget, loading } = useTargetStore();
const [search, setSearch] = useState('');
const [contextMenu, setContextMenu] = useState<string | null>(null);
const filteredTargets = targets.filter(t =>
t.name.toLowerCase().includes(search.toLowerCase())
);
const handleSelectTarget = async (target: Target) => {
selectTarget(target);
await fetchTarget(target.id);
};
return (
<div className="h-full flex flex-col bg-slate-900/50">
{/* Header */}
<div className="p-3 border-b border-slate-800">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-slate-400" />
<span className="text-sm font-medium text-slate-300">Targets</span>
</div>
<span className="text-xs text-slate-500 font-mono">{targets.length}</span>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500" />
<input
type="text"
placeholder="Search targets..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input-field w-full pl-8 py-1.5 text-xs"
/>
</div>
</div>
{/* Target list */}
<div className="flex-1 overflow-y-auto">
{loading && targets.length === 0 ? (
<div className="p-4 space-y-2">
{[1, 2, 3].map(i => (
<div key={i} className="h-16 shimmer rounded" />
))}
</div>
) : filteredTargets.length === 0 ? (
<div className="p-4 text-center">
<UserCircle2 className="w-10 h-10 text-slate-700 mx-auto mb-2" />
<p className="text-sm text-slate-500">
{search ? 'No targets found' : 'No targets yet'}
</p>
<p className="text-xs text-slate-600 mt-1">
Add a target to get started
</p>
</div>
) : (
<div className="p-2 space-y-1">
{filteredTargets.map(target => (
<TargetItem
key={target.id}
target={target}
isSelected={selectedTarget?.id === target.id}
onSelect={() => handleSelectTarget(target)}
showContextMenu={contextMenu === target.id}
onToggleContext={() => setContextMenu(contextMenu === target.id ? null : target.id)}
onAddProfile={onAddProfile}
/>
))}
</div>
)}
</div>
</div>
);
}
interface TargetItemProps {
target: Target;
isSelected: boolean;
onSelect: () => void;
showContextMenu: boolean;
onToggleContext: () => void;
onAddProfile: () => void;
}
function TargetItem({
target,
isSelected,
onSelect,
showContextMenu,
onToggleContext,
onAddProfile
}: TargetItemProps) {
const { deleteTarget } = useTargetStore();
const handleDelete = async (e: React.MouseEvent) => {
e.stopPropagation();
if (confirm(`Delete target "${target.name}"?`)) {
await deleteTarget(target.id);
}
};
return (
<div className="relative">
<button
onClick={onSelect}
className={`w-full text-left p-3 rounded-lg transition-all ${
isSelected
? 'bg-slate-800 border border-accent-cyan/30'
: 'hover:bg-slate-800/50 border border-transparent'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-200 truncate">
{target.name}
</span>
{isSelected && (
<ChevronRight className="w-3.5 h-3.5 text-accent-cyan flex-shrink-0" />
)}
</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-slate-500">
{target.profile_count || 0} profiles
</span>
<span className="text-xs text-slate-600"></span>
<span className="text-[10px] text-slate-600 font-mono">
{target.updated_at && !isNaN(new Date(target.updated_at).getTime())
? formatDistanceToNow(new Date(target.updated_at), { addSuffix: true })
: 'just now'}
</span>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onToggleContext();
}}
className="p-1 rounded hover:bg-slate-700 text-slate-500 hover:text-slate-300"
>
<MoreHorizontal className="w-4 h-4" />
</button>
</div>
</button>
{/* Context menu */}
{showContextMenu && (
<>
<div
className="fixed inset-0 z-10"
onClick={onToggleContext}
/>
<div className="absolute right-2 top-10 z-20 w-40 py-1 bg-slate-800 border border-slate-700 rounded-lg shadow-xl">
<button
onClick={(e) => {
e.stopPropagation();
onSelect();
onAddProfile();
onToggleContext();
}}
className="w-full px-3 py-2 text-left text-xs text-slate-300 hover:bg-slate-700 flex items-center gap-2"
>
<Plus className="w-3.5 h-3.5" />
Add Profile
</button>
<button
className="w-full px-3 py-2 text-left text-xs text-slate-300 hover:bg-slate-700 flex items-center gap-2"
>
<Edit2 className="w-3.5 h-3.5" />
Edit Target
</button>
<div className="my-1 border-t border-slate-700" />
<button
onClick={handleDelete}
className="w-full px-3 py-2 text-left text-xs text-accent-rose hover:bg-accent-rose/10 flex items-center gap-2"
>
<Trash2 className="w-3.5 h-3.5" />
Delete Target
</button>
</div>
</>
)}
</div>
);
}

125
frontend/src/index.css Normal file
View File

@@ -0,0 +1,125 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Base styles */
:root {
--color-bg-primary: #020617;
--color-bg-secondary: #0f172a;
--color-bg-tertiary: #1e293b;
--color-text-primary: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-accent: #22d3ee;
}
html, body, #root {
height: 100%;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', system-ui, sans-serif;
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #0f172a;
}
::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #475569;
}
/* Custom utilities */
@layer utilities {
.text-gradient {
@apply bg-gradient-to-r from-accent-cyan to-accent-emerald bg-clip-text text-transparent;
}
.border-glow {
box-shadow: 0 0 10px rgba(34, 211, 238, 0.2);
}
.card {
@apply bg-slate-900 border border-slate-800 rounded-lg;
}
.card-hover {
@apply hover:border-slate-700 hover:bg-slate-800/50 transition-all duration-200;
}
.input-field {
@apply bg-slate-900 border border-slate-700 rounded px-3 py-2 text-sm text-slate-100
placeholder:text-slate-500 focus:outline-none focus:border-accent-cyan focus:ring-1 focus:ring-accent-cyan/30;
}
.btn-primary {
@apply bg-accent-cyan text-slate-950 px-4 py-2 rounded font-medium text-sm
hover:bg-accent-cyan/90 active:bg-accent-cyan/80 transition-colors
disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-secondary {
@apply bg-slate-800 text-slate-200 px-4 py-2 rounded font-medium text-sm border border-slate-700
hover:bg-slate-700 hover:border-slate-600 active:bg-slate-600 transition-colors
disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-danger {
@apply bg-accent-rose/10 text-accent-rose px-4 py-2 rounded font-medium text-sm border border-accent-rose/30
hover:bg-accent-rose/20 active:bg-accent-rose/30 transition-colors
disabled:opacity-50 disabled:cursor-not-allowed;
}
.status-indicator {
@apply w-2 h-2 rounded-full;
}
.status-active {
@apply bg-accent-emerald animate-pulse;
}
.status-warning {
@apply bg-accent-amber;
}
.status-error {
@apply bg-accent-rose;
}
.status-inactive {
@apply bg-slate-600;
}
}
/* Monospace text */
.font-mono {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
/* Animation for loading states */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.shimmer {
background: linear-gradient(90deg, #1e293b 25%, #334155 50%, #1e293b 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,81 @@
import { useEffect, useState } from 'react';
import { useAuthStore } from '../stores/authStore';
import { useTargetStore } from '../stores/targetStore';
import { useScraperStore } from '../stores/scraperStore';
import { TargetList } from '../components/TargetList';
import { MainDataFeed } from '../components/MainDataFeed';
import { ScraperPanel } from '../components/ScraperPanel';
import { Header } from '../components/Header';
import { AddTargetModal } from '../components/AddTargetModal';
import { AddProfileModal } from '../components/AddProfileModal';
import { AddSessionModal } from '../components/AddSessionModal';
export function Dashboard() {
const { checkAuth } = useAuthStore();
const { fetchTargets } = useTargetStore();
const { fetchJobs, fetchSessions, initSocket, disconnectSocket } = useScraperStore();
const [showAddTarget, setShowAddTarget] = useState(false);
const [showAddProfile, setShowAddProfile] = useState(false);
const [showAddSession, setShowAddSession] = useState(false);
useEffect(() => {
// Initialize
checkAuth();
fetchTargets();
fetchJobs();
fetchSessions();
initSocket();
// Polling for updates
const interval = setInterval(() => {
fetchJobs();
fetchSessions();
}, 10000);
return () => {
clearInterval(interval);
disconnectSocket();
};
}, []);
return (
<div className="h-screen flex flex-col overflow-hidden bg-slate-950">
<Header
onAddTarget={() => setShowAddTarget(true)}
onAddSession={() => setShowAddSession(true)}
/>
{/* Main 3-column layout */}
<div className="flex-1 flex overflow-hidden">
{/* Left column - Target List */}
<aside className="w-72 flex-shrink-0 border-r border-slate-800 overflow-hidden">
<TargetList onAddProfile={() => setShowAddProfile(true)} />
</aside>
{/* Center column - Main Data Feed */}
<main className="flex-1 overflow-hidden">
<MainDataFeed />
</main>
{/* Right column - Scraper Status & Logs */}
<aside className="w-80 flex-shrink-0 border-l border-slate-800 overflow-hidden">
<ScraperPanel />
</aside>
</div>
{/* Modals */}
{showAddTarget && (
<AddTargetModal onClose={() => setShowAddTarget(false)} />
)}
{showAddProfile && (
<AddProfileModal onClose={() => setShowAddProfile(false)} />
)}
{showAddSession && (
<AddSessionModal onClose={() => setShowAddSession(false)} />
)}
</div>
);
}

View File

@@ -0,0 +1,148 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../stores/authStore';
import { Shield, AlertCircle, Lock, Eye, EyeOff } from 'lucide-react';
export function LoginPage() {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const navigate = useNavigate();
const { login, checkAuth, isAuthenticated } = useAuthStore();
useEffect(() => {
// Check if already authenticated
checkAuth().then((authenticated) => {
if (authenticated) {
navigate('/');
}
});
}, []);
useEffect(() => {
if (isAuthenticated) {
navigate('/');
}
}, [isAuthenticated, navigate]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await login(password);
navigate('/');
} catch (err: any) {
setError(err.message || 'Authentication failed');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-slate-950 px-4">
{/* Background pattern */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950" />
<div className="absolute inset-0 opacity-30">
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-accent-cyan/5 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-accent-violet/5 rounded-full blur-3xl" />
</div>
{/* Grid pattern */}
<svg className="absolute inset-0 w-full h-full opacity-[0.03]">
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="currentColor" strokeWidth="1" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
</svg>
</div>
<div className="relative w-full max-w-md">
{/* Logo/Header */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-accent-cyan/20 to-accent-violet/20 border border-accent-cyan/30 mb-4">
<Shield className="w-8 h-8 text-accent-cyan" />
</div>
<h1 className="text-2xl font-bold text-slate-100 mb-1">OSINT Platform</h1>
<p className="text-sm text-slate-500">Private Intelligence Automation</p>
</div>
{/* Login Card */}
<div className="card p-6 border-slate-800 shadow-xl shadow-black/20">
<div className="flex items-center gap-2 mb-6 pb-4 border-b border-slate-800">
<Lock className="w-4 h-4 text-slate-500" />
<span className="text-sm font-medium text-slate-400">Master Authentication</span>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="flex items-center gap-2 p-3 rounded bg-accent-rose/10 border border-accent-rose/20 text-accent-rose text-sm">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{error}</span>
</div>
)}
<div>
<label htmlFor="password" className="block text-sm font-medium text-slate-400 mb-2">
Master Password
</label>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input-field w-full pr-10 font-mono"
placeholder="Enter your master password"
autoFocus
disabled={loading}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition-colors"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<button
type="submit"
disabled={!password || loading}
className="btn-primary w-full flex items-center justify-center gap-2"
>
{loading ? (
<>
<div className="w-4 h-4 border-2 border-slate-950 border-t-transparent rounded-full animate-spin" />
<span>Authenticating...</span>
</>
) : (
<>
<Shield className="w-4 h-4" />
<span>Authenticate</span>
</>
)}
</button>
</form>
<div className="mt-6 pt-4 border-t border-slate-800">
<div className="flex items-center gap-2 text-xs text-slate-600">
<div className="w-1.5 h-1.5 rounded-full bg-accent-emerald animate-pulse" />
<span>Encrypted connection Rate limited</span>
</div>
</div>
</div>
{/* Footer */}
<p className="text-center text-xs text-slate-600 mt-6">
All access attempts are logged and monitored.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface AuthState {
isAuthenticated: boolean;
token: string | null;
login: (password: string) => Promise<boolean>;
logout: () => void;
checkAuth: () => Promise<boolean>;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
isAuthenticated: false,
token: null,
login: async (password: string) => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
credentials: 'include',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Login failed');
}
const data = await response.json();
set({ isAuthenticated: true, token: data.token });
return true;
} catch (error) {
set({ isAuthenticated: false, token: null });
throw error;
}
},
logout: async () => {
try {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
});
} catch (e) {
// Ignore errors
}
set({ isAuthenticated: false, token: null });
},
checkAuth: async () => {
const { token } = get();
if (!token) {
set({ isAuthenticated: false });
return false;
}
try {
const response = await fetch('/api/auth/verify', {
headers: {
Authorization: `Bearer ${token}`,
},
credentials: 'include',
});
if (response.ok) {
set({ isAuthenticated: true });
return true;
}
} catch (e) {
// Token invalid
}
set({ isAuthenticated: false, token: null });
return false;
},
}),
{
name: 'osint-auth',
partialize: (state) => ({ token: state.token }),
}
)
);

View File

@@ -0,0 +1,206 @@
import { create } from 'zustand';
import { io, Socket } from 'socket.io-client';
import { useAuthStore } from './authStore';
export interface ScraperJob {
id: string;
target_id?: string;
profile_id?: string;
platform: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
progress: number;
result?: string;
error?: string;
target_name?: string;
profile_username?: string;
started_at?: string;
completed_at?: string;
created_at: string;
logs?: ScraperLog[];
}
export interface ScraperLog {
id: number;
job_id: string;
level: string;
message: string;
timestamp: string;
}
export interface Session {
id: string;
platform: string;
session_name: string;
user_agent?: string;
proxy?: string;
status: 'active' | 'expired' | 'invalid';
last_validated?: string;
created_at: string;
updated_at: string;
}
interface ScraperState {
jobs: ScraperJob[];
activeJobs: ScraperJob[];
sessions: Session[];
logs: Map<string, ScraperLog[]>;
socket: Socket | null;
loading: boolean;
error: string | null;
initSocket: () => void;
disconnectSocket: () => void;
fetchJobs: () => Promise<void>;
fetchSessions: () => Promise<void>;
startJob: (config: { platform: string; profileUrl?: string; targetId?: string; profileId?: string }) => Promise<ScraperJob>;
cancelJob: (jobId: string) => Promise<void>;
subscribeToJob: (jobId: string) => void;
addSession: (data: { platform: string; session_name: string; cookies: any[]; user_agent?: string }) => Promise<void>;
deleteSession: (id: string) => Promise<void>;
}
export const useScraperStore = create<ScraperState>((set, get) => ({
jobs: [],
activeJobs: [],
sessions: [],
logs: new Map(),
socket: null,
loading: false,
error: null,
initSocket: () => {
const token = useAuthStore.getState().token;
if (!token) return;
const socket = io({
auth: { token },
});
socket.on('connect', () => {
console.log('Socket connected');
});
socket.on('scraper:log', (log: ScraperLog & { jobId: string }) => {
const logs = new Map(get().logs);
const jobLogs = logs.get(log.jobId) || [];
logs.set(log.jobId, [...jobLogs, log]);
set({ logs });
});
socket.on('scraper:status', (update: { jobId: string; status: string; progress?: number }) => {
const jobs = get().jobs.map(job =>
job.id === update.jobId
? { ...job, status: update.status as any, progress: update.progress ?? job.progress }
: job
);
set({ jobs });
});
socket.on('scraper:jobUpdate', () => {
get().fetchJobs();
});
set({ socket });
},
disconnectSocket: () => {
const { socket } = get();
if (socket) {
socket.disconnect();
set({ socket: null });
}
},
fetchJobs: async () => {
set({ loading: true });
try {
const response = await fetch('/api/scraper/jobs', { credentials: 'include' });
if (!response.ok) throw new Error('Failed to fetch jobs');
const jobs = await response.json();
const statusResponse = await fetch('/api/scraper/status', { credentials: 'include' });
const statusData = await statusResponse.json();
set({
jobs,
activeJobs: statusData.activeJobs || [],
loading: false
});
} catch (error: any) {
set({ error: error.message, loading: false });
}
},
fetchSessions: async () => {
try {
const response = await fetch('/api/sessions', { credentials: 'include' });
if (!response.ok) throw new Error('Failed to fetch sessions');
const sessions = await response.json();
set({ sessions });
} catch (error: any) {
set({ error: error.message });
}
},
startJob: async (config) => {
const response = await fetch('/api/scraper/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
platform: config.platform,
profile_url: config.profileUrl,
target_id: config.targetId,
profile_id: config.profileId,
}),
credentials: 'include',
});
if (!response.ok) throw new Error('Failed to start job');
const job = await response.json();
// Subscribe to job updates
get().subscribeToJob(job.id);
await get().fetchJobs();
return job;
},
cancelJob: async (jobId: string) => {
const response = await fetch(`/api/scraper/jobs/${jobId}/cancel`, {
method: 'POST',
credentials: 'include',
});
if (!response.ok) throw new Error('Failed to cancel job');
await get().fetchJobs();
},
subscribeToJob: (jobId: string) => {
const { socket } = get();
if (socket) {
socket.emit('subscribe:scraper', jobId);
}
},
addSession: async (data) => {
const response = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
credentials: 'include',
});
if (!response.ok) throw new Error('Failed to add session');
await get().fetchSessions();
},
deleteSession: async (id: string) => {
const response = await fetch(`/api/sessions/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) throw new Error('Failed to delete session');
await get().fetchSessions();
},
}));

View File

@@ -0,0 +1,143 @@
import { create } from 'zustand';
export interface Target {
id: string;
name: string;
notes?: string;
profile_count?: number;
created_at: string;
updated_at: string;
profiles?: SocialProfile[];
}
export interface SocialProfile {
id: string;
target_id: string;
platform: string;
username?: string;
profile_url?: string;
profile_data?: string;
last_scraped?: string;
created_at: string;
}
interface TargetState {
targets: Target[];
selectedTarget: Target | null;
loading: boolean;
error: string | null;
fetchTargets: () => Promise<void>;
fetchTarget: (id: string) => Promise<void>;
selectTarget: (target: Target | null) => void;
createTarget: (name: string, notes?: string) => Promise<Target>;
updateTarget: (id: string, name: string, notes?: string) => Promise<void>;
deleteTarget: (id: string) => Promise<void>;
addProfile: (targetId: string, platform: string, username?: string, profileUrl?: string) => Promise<void>;
deleteProfile: (targetId: string, profileId: string) => Promise<void>;
}
export const useTargetStore = create<TargetState>((set, get) => ({
targets: [],
selectedTarget: null,
loading: false,
error: null,
fetchTargets: async () => {
set({ loading: true, error: null });
try {
const response = await fetch('/api/targets', { credentials: 'include' });
if (!response.ok) throw new Error('Failed to fetch targets');
const data = await response.json();
const targets = data.map((t: any) => ({
...t,
updated_at: t.updatedAt || t.updated_at,
created_at: t.createdAt || t.created_at
}));
set({ targets, loading: false });
} catch (error: any) {
set({ error: error.message, loading: false });
}
},
fetchTarget: async (id: string) => {
set({ loading: true, error: null });
try {
const response = await fetch(`/api/targets/${id}`, { credentials: 'include' });
if (!response.ok) throw new Error('Failed to fetch target');
const t = await response.json();
const target = {
...t,
updated_at: t.updatedAt || t.updated_at,
created_at: t.createdAt || t.created_at
};
set({ selectedTarget: target, loading: false });
} catch (error: any) {
set({ error: error.message, loading: false });
}
},
selectTarget: (target) => {
set({ selectedTarget: target });
},
createTarget: async (name: string, notes?: string) => {
const response = await fetch('/api/targets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, notes }),
credentials: 'include',
});
if (!response.ok) throw new Error('Failed to create target');
const target = await response.json();
await get().fetchTargets();
return target;
},
updateTarget: async (id: string, name: string, notes?: string) => {
const response = await fetch(`/api/targets/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, notes }),
credentials: 'include',
});
if (!response.ok) throw new Error('Failed to update target');
await get().fetchTargets();
if (get().selectedTarget?.id === id) {
await get().fetchTarget(id);
}
},
deleteTarget: async (id: string) => {
const response = await fetch(`/api/targets/${id}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) throw new Error('Failed to delete target');
if (get().selectedTarget?.id === id) {
set({ selectedTarget: null });
}
await get().fetchTargets();
},
addProfile: async (targetId: string, platform: string, username?: string, profileUrl?: string) => {
const response = await fetch(`/api/targets/${targetId}/profiles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ platform, username, profile_url: profileUrl }),
credentials: 'include',
});
if (!response.ok) throw new Error('Failed to add profile');
await get().fetchTarget(targetId);
await get().fetchTargets();
},
deleteProfile: async (targetId: string, profileId: string) => {
const response = await fetch(`/api/targets/${targetId}/profiles/${profileId}`, {
method: 'DELETE',
credentials: 'include',
});
if (!response.ok) throw new Error('Failed to delete profile');
await get().fetchTarget(targetId);
await get().fetchTargets();
},
}));

View File

@@ -0,0 +1,48 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
// Midnight Utility Theme
slate: {
950: '#020617',
900: '#0f172a',
800: '#1e293b',
700: '#334155',
600: '#475569',
500: '#64748b',
400: '#94a3b8',
300: '#cbd5e1',
200: '#e2e8f0',
100: '#f1f5f9',
},
accent: {
cyan: '#22d3ee',
emerald: '#10b981',
amber: '#f59e0b',
rose: '#f43f5e',
violet: '#8b5cf6',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
},
animation: {
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'glow': 'glow 2s ease-in-out infinite alternate',
},
keyframes: {
glow: {
'0%': { boxShadow: '0 0 5px rgba(34, 211, 238, 0.3)' },
'100%': { boxShadow: '0 0 20px rgba(34, 211, 238, 0.6)' },
},
},
},
},
plugins: [],
}

25
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

25
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/socket.io': {
target: 'http://localhost:3001',
changeOrigin: true,
ws: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
},
})