mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-29 18:39:32 +02:00
e89e992293
New features: - NASA GIBS (MODIS Terra) daily satellite imagery with 30-day time slider - Esri World Imagery high-res satellite layer (sub-meter, zoom 18+) - KiwiSDR SDR receivers on map with embedded radio tuner - Sentinel-2 intel card — right-click for recent satellite photo popup - LOCATE bar — search by coordinates or place name (Nominatim geocoding) - SATELLITE style preset in bottom bar cycling - v0.4 changelog modal on first launch Fixes: - Satellite imagery renders below data icons (imagery-ceiling anchor) - Sentinel-2 opens full-res PNG directly (not STAC catalog JSON) - Light/dark theme: UI stays dark, only map basemap changes Security: - Removed test files with hardcoded API keys from tracking - Removed .git_backup directory from tracking - Updated .gitignore to exclude test files, dev scripts, cache files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
293 lines
19 KiB
TypeScript
293 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { API_BASE } from "@/lib/api";
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import { Settings, ExternalLink, Key, Shield, X, Save, ChevronDown, ChevronUp } from "lucide-react";
|
|
|
|
interface ApiEntry {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
category: string;
|
|
url: string | null;
|
|
required: boolean;
|
|
has_key: boolean;
|
|
env_key: string | null;
|
|
value_obfuscated: string | null;
|
|
is_set: boolean;
|
|
}
|
|
|
|
// Category colors for the tactical UI
|
|
const CATEGORY_COLORS: Record<string, string> = {
|
|
Aviation: "text-cyan-400 border-cyan-500/30 bg-cyan-950/20",
|
|
Maritime: "text-blue-400 border-blue-500/30 bg-blue-950/20",
|
|
Geophysical: "text-orange-400 border-orange-500/30 bg-orange-950/20",
|
|
Space: "text-purple-400 border-purple-500/30 bg-purple-950/20",
|
|
Intelligence: "text-red-400 border-red-500/30 bg-red-950/20",
|
|
Geolocation: "text-green-400 border-green-500/30 bg-green-950/20",
|
|
Weather: "text-yellow-400 border-yellow-500/30 bg-yellow-950/20",
|
|
Markets: "text-emerald-400 border-emerald-500/30 bg-emerald-950/20",
|
|
SIGINT: "text-rose-400 border-rose-500/30 bg-rose-950/20",
|
|
};
|
|
|
|
const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
|
|
const [apis, setApis] = useState<ApiEntry[]>([]);
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [editValue, setEditValue] = useState("");
|
|
const [saving, setSaving] = useState(false);
|
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(["Aviation", "Maritime"]));
|
|
|
|
const fetchKeys = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/settings/api-keys`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setApis(data);
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to fetch API keys", e);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (isOpen) fetchKeys();
|
|
}, [isOpen, fetchKeys]);
|
|
|
|
const startEditing = (api: ApiEntry) => {
|
|
setEditingId(api.id);
|
|
setEditValue("");
|
|
};
|
|
|
|
const saveKey = async (api: ApiEntry) => {
|
|
if (!api.env_key) return;
|
|
setSaving(true);
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/settings/api-keys`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ env_key: api.env_key, value: editValue }),
|
|
});
|
|
if (res.ok) {
|
|
setEditingId(null);
|
|
fetchKeys(); // Refresh to get new obfuscated value
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to save API key", e);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const toggleCategory = (cat: string) => {
|
|
setExpandedCategories(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(cat)) next.delete(cat);
|
|
else next.add(cat);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
// Group APIs by category
|
|
const grouped = apis.reduce<Record<string, ApiEntry[]>>((acc, api) => {
|
|
if (!acc[api.category]) acc[api.category] = [];
|
|
acc[api.category].push(api);
|
|
return acc;
|
|
}, {});
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{isOpen && (
|
|
<>
|
|
{/* Backdrop */}
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 bg-black/70 backdrop-blur-sm z-[9998]"
|
|
onClick={onClose}
|
|
/>
|
|
|
|
{/* Settings Panel */}
|
|
<motion.div
|
|
initial={{ opacity: 0, x: -300 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: -300 }}
|
|
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
|
className="fixed left-0 top-0 bottom-0 w-[480px] bg-[var(--bg-secondary)]/95 backdrop-blur-xl border-r border-cyan-900/50 z-[9999] flex flex-col shadow-[4px_0_40px_rgba(0,0,0,0.3)]"
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-6 border-b border-[var(--border-primary)]/80">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-lg bg-cyan-500/10 border border-cyan-500/30 flex items-center justify-center">
|
|
<Settings size={16} className="text-cyan-400" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-sm font-bold tracking-[0.2em] text-[var(--text-primary)] font-mono">SYSTEM CONFIG</h2>
|
|
<span className="text-[9px] text-[var(--text-muted)] font-mono tracking-widest">API KEY REGISTRY</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="w-8 h-8 rounded-lg border border-[var(--border-primary)] hover:border-red-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 transition-all hover:bg-red-950/20"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Info Banner */}
|
|
<div className="mx-4 mt-4 p-3 rounded-lg border border-cyan-900/30 bg-cyan-950/10">
|
|
<div className="flex items-start gap-2">
|
|
<Shield size={12} className="text-cyan-500 mt-0.5 flex-shrink-0" />
|
|
<p className="text-[10px] text-[var(--text-secondary)] font-mono leading-relaxed">
|
|
API keys are stored locally in the backend <span className="text-cyan-400">.env</span> file. Keys marked with <Key size={8} className="inline text-yellow-500" /> are required for full functionality. Public APIs need no key.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* API List */}
|
|
<div className="flex-1 overflow-y-auto styled-scrollbar p-4 space-y-3">
|
|
{Object.entries(grouped).map(([category, categoryApis]) => {
|
|
const colorClass = CATEGORY_COLORS[category] || "text-gray-400 border-gray-700 bg-gray-900/20";
|
|
const isExpanded = expandedCategories.has(category);
|
|
|
|
return (
|
|
<div key={category} className="rounded-lg border border-[var(--border-primary)]/60 overflow-hidden">
|
|
{/* Category Header */}
|
|
<button
|
|
onClick={() => toggleCategory(category)}
|
|
className="w-full flex items-center justify-between px-4 py-2.5 bg-[var(--bg-secondary)]/50 hover:bg-[var(--bg-secondary)]/80 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className={`text-[9px] font-mono tracking-widest font-bold px-2 py-0.5 rounded border ${colorClass}`}>
|
|
{category.toUpperCase()}
|
|
</span>
|
|
<span className="text-[10px] text-[var(--text-muted)] font-mono">
|
|
{categoryApis.length} {categoryApis.length === 1 ? 'service' : 'services'}
|
|
</span>
|
|
</div>
|
|
{isExpanded ? <ChevronUp size={12} className="text-[var(--text-muted)]" /> : <ChevronDown size={12} className="text-[var(--text-muted)]" />}
|
|
</button>
|
|
|
|
{/* APIs in Category */}
|
|
<AnimatePresence>
|
|
{isExpanded && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: "auto", opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
{categoryApis.map((api) => (
|
|
<div key={api.id} className="border-t border-[var(--border-primary)]/40 px-4 py-3 hover:bg-[var(--bg-secondary)]/30 transition-colors">
|
|
{/* API Name + Status */}
|
|
<div className="flex items-center justify-between mb-1">
|
|
<div className="flex items-center gap-2">
|
|
{api.required && <Key size={10} className="text-yellow-500" />}
|
|
<span className="text-xs font-mono text-[var(--text-primary)] font-medium">{api.name}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
{api.has_key ? (
|
|
api.is_set ? (
|
|
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-green-500/30 text-green-400 bg-green-950/20">
|
|
KEY SET
|
|
</span>
|
|
) : (
|
|
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-yellow-500/30 text-yellow-400 bg-yellow-950/20">
|
|
MISSING
|
|
</span>
|
|
)
|
|
) : (
|
|
<span className="text-[8px] font-mono px-1.5 py-0.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)]">
|
|
PUBLIC
|
|
</span>
|
|
)}
|
|
{api.url && (
|
|
<a
|
|
href={api.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-[var(--text-muted)] hover:text-cyan-400 transition-colors"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<ExternalLink size={10} />
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<p className="text-[10px] text-[var(--text-muted)] font-mono leading-relaxed mb-2">
|
|
{api.description}
|
|
</p>
|
|
|
|
{/* Key Field (only for APIs with keys) */}
|
|
{api.has_key && (
|
|
<div className="mt-2">
|
|
{editingId === api.id ? (
|
|
/* Edit Mode */
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={editValue}
|
|
onChange={(e) => setEditValue(e.target.value)}
|
|
className="flex-1 bg-black/60 border border-cyan-900/50 rounded px-2 py-1.5 text-[11px] font-mono text-cyan-300 outline-none focus:border-cyan-500/70 transition-colors"
|
|
placeholder="Enter API key..."
|
|
autoFocus
|
|
/>
|
|
<button
|
|
onClick={() => saveKey(api)}
|
|
disabled={saving}
|
|
className="px-3 py-1.5 rounded bg-cyan-500/20 border border-cyan-500/40 text-cyan-400 hover:bg-cyan-500/30 transition-colors text-[10px] font-mono flex items-center gap-1"
|
|
>
|
|
<Save size={10} />
|
|
{saving ? "..." : "SAVE"}
|
|
</button>
|
|
<button
|
|
onClick={() => setEditingId(null)}
|
|
className="px-2 py-1.5 rounded border border-[var(--border-primary)] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:border-[var(--border-secondary)] transition-colors text-[10px] font-mono"
|
|
>
|
|
ESC
|
|
</button>
|
|
</div>
|
|
) : (
|
|
/* Display Mode */
|
|
<div className="flex items-center gap-1.5">
|
|
<div
|
|
className="flex-1 bg-[var(--bg-primary)]/40 border border-[var(--border-primary)] rounded px-2.5 py-1.5 font-mono text-[11px] cursor-pointer hover:border-[var(--border-secondary)] transition-colors select-none"
|
|
onClick={() => startEditing(api)}
|
|
>
|
|
<span className="text-[var(--text-muted)] tracking-wider">
|
|
{api.is_set ? api.value_obfuscated : "Click to set key..."}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="p-4 border-t border-[var(--border-primary)]/80">
|
|
<div className="flex items-center justify-between text-[9px] text-[var(--text-muted)] font-mono">
|
|
<span>{apis.length} REGISTERED APIs</span>
|
|
<span>{apis.filter(a => a.has_key).length} KEYS CONFIGURED</span>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
});
|
|
|
|
export default SettingsPanel;
|