mirror of
https://github.com/kharonsec/br-acc
synced 2026-04-25 17:15:02 +02:00
Frontend fixes: investigation 401, navigation redesign, route compat, light mode, i18n
- Fix listInvestigations trailing slash (307 redirect -> 401) - Add ExposureFactor, ExposureResponse, TimelineEvent, TimelineResponse, HealthResponse interfaces - Add getEntityExposure, getEntityTimeline, getHealthStatus API functions - Simplify nav to 3 items (Dashboard, Search, Investigations) - Add /app/analysis/:entityId route with lazy-loaded EntityAnalysis placeholder - Add /app/graph/:entityId -> /app/analysis/:entityId redirect - Update all /app/graph/ references to /app/analysis/ (SearchResults, Dashboard, Patterns) - Add light mode CSS variables with [data-theme="light"] selector - Add theme toggle (Sun/Moon) in sidebar, persisted to localStorage - StatusBar polls /api/v1/meta/health every 30s for connectivity status - Fix keyboard shortcuts: allow Cmd+K in input/textarea fields - Add title attrs to collapsed ControlsSidebar icons - Fix ControlsSidebar label truncation (overflow: visible) - Add favicon.svg - Add error toast on Dashboard search failure - Add aria-label to logout button - Add 60+ i18n keys (analysis.*, nav.theme*) in PT-BR and EN Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>ICARUS</title>
|
||||
<style>
|
||||
body {
|
||||
|
||||
4
frontend/public/favicon.svg
Normal file
4
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="4" fill="#060a07"/>
|
||||
<text x="16" y="22" font-family="sans-serif" font-size="18" font-weight="700" fill="#ff9a3c" text-anchor="middle">I</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 248 B |
@@ -1,23 +1,39 @@
|
||||
import { useEffect } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router";
|
||||
import { lazy, Suspense, useEffect } from "react";
|
||||
import { Navigate, Route, Routes, useParams } from "react-router";
|
||||
|
||||
import { AppShell } from "./components/common/AppShell";
|
||||
import { PublicShell } from "./components/common/PublicShell";
|
||||
import { Spinner } from "./components/common/Spinner";
|
||||
import { Baseline } from "./pages/Baseline";
|
||||
import { GraphExplorer } from "./pages/GraphExplorer";
|
||||
import { Home } from "./pages/Home";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { Investigations } from "./pages/Investigations";
|
||||
import { Landing } from "./pages/Landing";
|
||||
import { Login } from "./pages/Login";
|
||||
import { Patterns } from "./pages/Patterns";
|
||||
import { Register } from "./pages/Register";
|
||||
import { Search } from "./pages/Search";
|
||||
import { SharedInvestigation } from "./pages/SharedInvestigation";
|
||||
import { useAuthStore } from "./stores/auth";
|
||||
|
||||
const EntityAnalysis = lazy(() => import("./pages/EntityAnalysis").then((m) => ({ default: m.EntityAnalysis })));
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
if (!token) return <Navigate to="/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function RedirectIfAuth({ children }: { children: React.ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
if (token) return <Navigate to="/app" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function GraphRedirect() {
|
||||
const { entityId } = useParams();
|
||||
return <Navigate to={`/app/analysis/${entityId}`} replace />;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const restore = useAuthStore((s) => s.restore);
|
||||
|
||||
@@ -26,43 +42,45 @@ export function App() {
|
||||
}, [restore]);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/search" element={<Search />} />
|
||||
<Route path="/graph/:entityId" element={<GraphExplorer />} />
|
||||
<Route path="/patterns" element={<Patterns />} />
|
||||
<Route path="/patterns/:entityId" element={<Patterns />} />
|
||||
<Route
|
||||
path="/baseline/:entityId"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Baseline />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/investigations"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Investigations />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/investigations/shared/:token"
|
||||
element={<SharedInvestigation />}
|
||||
/>
|
||||
<Route
|
||||
path="/investigations/:investigationId"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Investigations />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</AppShell>
|
||||
<Routes>
|
||||
{/* Public shell — landing, login, register */}
|
||||
<Route
|
||||
element={
|
||||
<RedirectIfAuth>
|
||||
<PublicShell />
|
||||
</RedirectIfAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<Landing />} />
|
||||
<Route path="login" element={<Login />} />
|
||||
<Route path="register" element={<Register />} />
|
||||
</Route>
|
||||
|
||||
{/* Public — shared investigation (no auth, no shell) */}
|
||||
<Route path="shared/:token" element={<SharedInvestigation />} />
|
||||
|
||||
{/* Authenticated shell — the intelligence workspace */}
|
||||
<Route
|
||||
path="app"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AppShell />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="search" element={<Search />} />
|
||||
<Route path="analysis/:entityId" element={<Suspense fallback={<Spinner />}><EntityAnalysis /></Suspense>} />
|
||||
<Route path="graph/:entityId" element={<GraphRedirect />} />
|
||||
<Route path="patterns" element={<Patterns />} />
|
||||
<Route path="patterns/:entityId" element={<Patterns />} />
|
||||
<Route path="baseline/:entityId" element={<Baseline />} />
|
||||
<Route path="investigations" element={<Investigations />} />
|
||||
<Route path="investigations/:investigationId" element={<Investigations />} />
|
||||
</Route>
|
||||
|
||||
{/* Catch-all */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -163,12 +163,13 @@ export function getGraphData(
|
||||
entityId: string,
|
||||
depth = 1,
|
||||
types?: string[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<GraphData> {
|
||||
const params = new URLSearchParams({ depth: String(depth) });
|
||||
if (types?.length) {
|
||||
params.set("entity_types", types.join(","));
|
||||
}
|
||||
return apiFetch<GraphData>(`/api/v1/graph/${encodeURIComponent(entityId)}?${params}`);
|
||||
return apiFetch<GraphData>(`/api/v1/graph/${encodeURIComponent(entityId)}?${params}`, { signal });
|
||||
}
|
||||
|
||||
// --- Baseline ---
|
||||
@@ -236,7 +237,7 @@ export function listInvestigations(
|
||||
size = 20,
|
||||
): Promise<InvestigationListResponse> {
|
||||
const params = new URLSearchParams({ page: String(page), size: String(size) });
|
||||
return apiFetch<InvestigationListResponse>(`/api/v1/investigations?${params}`);
|
||||
return apiFetch<InvestigationListResponse>(`/api/v1/investigations/?${params}`);
|
||||
}
|
||||
|
||||
export function getInvestigation(id: string): Promise<Investigation> {
|
||||
@@ -364,6 +365,78 @@ export function exportInvestigation(investigationId: string): Promise<Blob> {
|
||||
});
|
||||
}
|
||||
|
||||
// --- Stats ---
|
||||
|
||||
export interface StatsResponse {
|
||||
total_nodes: number;
|
||||
total_relationships: number;
|
||||
person_count: number;
|
||||
company_count: number;
|
||||
data_sources: number;
|
||||
indexes: number;
|
||||
}
|
||||
|
||||
export function getStats(): Promise<StatsResponse> {
|
||||
return apiFetch<StatsResponse>("/api/v1/meta/stats");
|
||||
}
|
||||
|
||||
// --- Exposure & Timeline ---
|
||||
|
||||
export interface ExposureFactor {
|
||||
name: string;
|
||||
value: number;
|
||||
percentile: number;
|
||||
weight: number;
|
||||
sources: string[];
|
||||
}
|
||||
|
||||
export interface ExposureResponse {
|
||||
entity_id: string;
|
||||
exposure_index: number;
|
||||
factors: ExposureFactor[];
|
||||
peer_group: string;
|
||||
peer_count: number;
|
||||
sources: SourceAttribution[];
|
||||
}
|
||||
|
||||
export interface TimelineEvent {
|
||||
id: string;
|
||||
date: string;
|
||||
label: string;
|
||||
entity_type: string;
|
||||
properties: Record<string, unknown>;
|
||||
sources: SourceAttribution[];
|
||||
}
|
||||
|
||||
export interface TimelineResponse {
|
||||
entity_id: string;
|
||||
events: TimelineEvent[];
|
||||
total: number;
|
||||
next_cursor: string | null;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export function getEntityExposure(entityId: string): Promise<ExposureResponse> {
|
||||
return apiFetch<ExposureResponse>(`/api/v1/entity/${encodeURIComponent(entityId)}/exposure`);
|
||||
}
|
||||
|
||||
export function getEntityTimeline(
|
||||
entityId: string,
|
||||
cursor?: string,
|
||||
limit = 50,
|
||||
): Promise<TimelineResponse> {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
return apiFetch<TimelineResponse>(`/api/v1/entity/${encodeURIComponent(entityId)}/timeline?${params}`);
|
||||
}
|
||||
|
||||
export function getHealthStatus(): Promise<HealthResponse> {
|
||||
return apiFetch<HealthResponse>("/api/v1/meta/health");
|
||||
}
|
||||
|
||||
export function exportInvestigationPDF(
|
||||
investigationId: string,
|
||||
lang = "pt",
|
||||
|
||||
@@ -1,99 +1,192 @@
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useLocation, useNavigate } from "react-router";
|
||||
import { Link, Outlet, useLocation, useNavigate } from "react-router";
|
||||
|
||||
import {
|
||||
BarChart3,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FolderOpen,
|
||||
LogOut,
|
||||
Moon,
|
||||
Search,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
|
||||
import { registerActions, type Action } from "@/actions/registry";
|
||||
import { CommandPalette } from "@/components/common/CommandPalette";
|
||||
import { Kbd } from "@/components/common/Kbd";
|
||||
import { KeyboardShortcutsHelp } from "@/components/common/KeyboardShortcutsHelp";
|
||||
import { StatusBar } from "@/components/common/StatusBar";
|
||||
import { ToastContainer } from "@/components/common/ToastContainer";
|
||||
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
import styles from "./AppShell.module.css";
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const NAV_ITEMS = [
|
||||
{ path: "/app", icon: BarChart3, labelKey: "nav.dashboard" },
|
||||
{ path: "/app/search", icon: Search, labelKey: "nav.search" },
|
||||
{ path: "/app/investigations", icon: FolderOpen, labelKey: "nav.investigations" },
|
||||
] as const;
|
||||
|
||||
export function AppShell() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const toggleLang = () => {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [commandOpen, setCommandOpen] = useState(false);
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
const [isMobileBlocked, setIsMobileBlocked] = useState(false);
|
||||
const [theme, setTheme] = useState<"dark" | "light">(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem("icarus_theme");
|
||||
if (saved === "light" || saved === "dark") return saved;
|
||||
} catch { /* noop */ }
|
||||
if (typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: light)").matches) return "light";
|
||||
return "dark";
|
||||
});
|
||||
|
||||
// Desktop-only: check viewport on mount and resize
|
||||
useEffect(() => {
|
||||
function check() {
|
||||
setIsMobileBlocked(window.innerWidth < 1024);
|
||||
}
|
||||
check();
|
||||
window.addEventListener("resize", check);
|
||||
return () => window.removeEventListener("resize", check);
|
||||
}, []);
|
||||
|
||||
const isGraphRoute = location.pathname.startsWith("/app/graph") || location.pathname.startsWith("/app/analysis");
|
||||
|
||||
// Auto-collapse sidebar on graph/analysis routes
|
||||
useEffect(() => {
|
||||
if (isGraphRoute) setSidebarCollapsed(true);
|
||||
}, [isGraphRoute]);
|
||||
|
||||
// Apply theme to document
|
||||
useEffect(() => {
|
||||
if (theme === "light") {
|
||||
document.documentElement.dataset.theme = "light";
|
||||
} else {
|
||||
delete document.documentElement.dataset.theme;
|
||||
}
|
||||
try { localStorage.setItem("icarus_theme", theme); } catch { /* noop */ }
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme((prev) => (prev === "dark" ? "light" : "dark"));
|
||||
}, []);
|
||||
|
||||
const toggleLang = useCallback(() => {
|
||||
const next = i18n.language === "pt-BR" ? "en" : "pt-BR";
|
||||
i18n.changeLanguage(next);
|
||||
};
|
||||
}, [i18n]);
|
||||
|
||||
const navLinkClass = (path: string) =>
|
||||
location.pathname.startsWith(path) ? styles.active : "";
|
||||
|
||||
const handleLogout = () => {
|
||||
const handleLogout = useCallback(() => {
|
||||
logout();
|
||||
navigate("/");
|
||||
}, [logout, navigate]);
|
||||
|
||||
// Register actions for command palette + keyboard shortcuts
|
||||
const actions: Action[] = useMemo(
|
||||
() => [
|
||||
{ id: "go-dashboard", label: t("command.goToDashboard"), shortcut: "cmd+1", group: t("command.navigation"), handler: () => navigate("/app") },
|
||||
{ id: "go-search", label: t("command.goToSearch"), shortcut: "cmd+2", group: t("command.navigation"), handler: () => navigate("/app/search") },
|
||||
{ id: "go-patterns", label: t("command.goToPatterns"), shortcut: "cmd+3", group: t("command.navigation"), handler: () => navigate("/app/patterns") },
|
||||
{ id: "go-investigations", label: t("command.goToInvestigations"), shortcut: "cmd+4", group: t("command.navigation"), handler: () => navigate("/app/investigations") },
|
||||
{ id: "toggle-sidebar", label: t("command.toggleSidebar"), shortcut: "cmd+b", group: t("command.actions"), handler: () => setSidebarCollapsed((p) => !p) },
|
||||
{ id: "command-palette", label: t("shortcuts.commandPalette"), shortcut: "cmd+k", group: t("command.actions"), handler: () => setCommandOpen(true) },
|
||||
{ id: "show-shortcuts", label: t("command.showShortcuts"), shortcut: "shift+?", group: t("command.actions"), handler: () => setShortcutsOpen(true) },
|
||||
],
|
||||
[t, navigate],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
registerActions(actions);
|
||||
}, [actions]);
|
||||
|
||||
useKeyboardShortcuts();
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === "/app") return location.pathname === "/app";
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
|
||||
if (isMobileBlocked) {
|
||||
return (
|
||||
<div className={styles.mobileBlock}>
|
||||
<h1 className={styles.mobileTitle}>{t("mobile.title")}</h1>
|
||||
<p className={styles.mobileMessage}>{t("mobile.message")}</p>
|
||||
<p className={styles.mobileHint}>{t("mobile.hint")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.shell}>
|
||||
<header className={styles.header}>
|
||||
<Link to="/" className={styles.logo}>
|
||||
{t("app.title")}
|
||||
</Link>
|
||||
<button
|
||||
className={styles.hamburger}
|
||||
onClick={() => setMenuOpen((prev) => !prev)}
|
||||
aria-label="Menu"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<nav className={`${styles.nav} ${menuOpen ? styles.navOpen : ""}`}>
|
||||
<Link
|
||||
to="/search"
|
||||
className={navLinkClass("/search")}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
{t("nav.search")}
|
||||
<nav className={`${styles.sidebar} ${sidebarCollapsed ? styles.collapsed : ""}`}>
|
||||
<div className={styles.sidebarHeader}>
|
||||
<Link to="/app" className={styles.logo}>
|
||||
{sidebarCollapsed ? "I" : "ICARUS"}
|
||||
</Link>
|
||||
<Link
|
||||
to="/patterns"
|
||||
className={navLinkClass("/patterns")}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
{t("nav.patterns")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/baseline"
|
||||
className={navLinkClass("/baseline")}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
{t("nav.baseline")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/investigations"
|
||||
className={navLinkClass("/investigations")}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
{t("nav.investigations")}
|
||||
</Link>
|
||||
</nav>
|
||||
<div className={styles.headerActions}>
|
||||
{token ? (
|
||||
<div className={styles.userArea}>
|
||||
{user && <span className={styles.userEmail}>{user.email}</span>}
|
||||
<button onClick={handleLogout} className={styles.authBtn}>
|
||||
{t("nav.logout")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Link to="/login" className={styles.authBtn}>
|
||||
{t("nav.login")}
|
||||
</div>
|
||||
|
||||
<div className={styles.navItems}>
|
||||
{NAV_ITEMS.map(({ path, icon: Icon, labelKey }) => (
|
||||
<Link
|
||||
key={path}
|
||||
to={path}
|
||||
className={`${styles.navItem} ${isActive(path) ? styles.navItemActive : ""}`}
|
||||
title={sidebarCollapsed ? t(labelKey) : undefined}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{!sidebarCollapsed && <span>{t(labelKey)}</span>}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.sidebarFooter}>
|
||||
{!sidebarCollapsed && (
|
||||
<button className={styles.cmdHint} onClick={() => setCommandOpen(true)}>
|
||||
<Kbd>⌘K</Kbd>
|
||||
</button>
|
||||
)}
|
||||
<button onClick={toggleLang} className={styles.langToggle}>
|
||||
<button className={styles.langToggle} onClick={toggleTheme} title={theme === "dark" ? t("nav.themeLight") : t("nav.themeDark")}>
|
||||
{theme === "dark" ? <Sun size={14} /> : <Moon size={14} />}
|
||||
</button>
|
||||
<button className={styles.langToggle} onClick={toggleLang} title={i18n.language === "pt-BR" ? "English" : "Portugues"}>
|
||||
{i18n.language === "pt-BR" ? "EN" : "PT"}
|
||||
</button>
|
||||
{user && !sidebarCollapsed && (
|
||||
<span className={styles.userEmail}>{user.email}</span>
|
||||
)}
|
||||
<button className={styles.logoutBtn} onClick={handleLogout} title={t("nav.logout")} aria-label={t("nav.logout")}>
|
||||
<LogOut size={16} />
|
||||
{!sidebarCollapsed && <span>{t("nav.logout")}</span>}
|
||||
</button>
|
||||
<button
|
||||
className={styles.collapseBtn}
|
||||
onClick={() => setSidebarCollapsed((p) => !p)}
|
||||
aria-label={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
{sidebarCollapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className={styles.main}>{children}</main>
|
||||
<footer className={styles.footer}>
|
||||
<span className={styles.disclaimer}>{t("app.disclaimer")}</span>
|
||||
</footer>
|
||||
</nav>
|
||||
|
||||
<div className={styles.content}>
|
||||
<main className={styles.main}><Outlet /></main>
|
||||
<StatusBar />
|
||||
</div>
|
||||
|
||||
<CommandPalette open={commandOpen} onOpenChange={setCommandOpen} />
|
||||
<KeyboardShortcutsHelp open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
64
frontend/src/components/common/StatusBar.tsx
Normal file
64
frontend/src/components/common/StatusBar.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getHealthStatus } from "@/api/client";
|
||||
|
||||
import styles from "./StatusBar.module.css";
|
||||
|
||||
interface StatusBarProps {
|
||||
nodeCount?: number;
|
||||
edgeCount?: number;
|
||||
}
|
||||
|
||||
export function StatusBar({ nodeCount, edgeCount }: StatusBarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isConnected, setIsConnected] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
function poll() {
|
||||
getHealthStatus()
|
||||
.then(() => { if (!cancelled) setIsConnected(true); })
|
||||
.catch(() => { if (!cancelled) setIsConnected(false); });
|
||||
}
|
||||
|
||||
poll();
|
||||
const interval = setInterval(poll, 30_000);
|
||||
return () => { cancelled = true; clearInterval(interval); };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.statusBar}>
|
||||
<div className={styles.left}>
|
||||
<span
|
||||
className={`${styles.dot} ${isConnected ? styles.connected : styles.disconnected}`}
|
||||
/>
|
||||
<span>
|
||||
{isConnected
|
||||
? t("statusBar.connected")
|
||||
: t("statusBar.disconnected")}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.center}>
|
||||
{nodeCount != null && (
|
||||
<span className={styles.stat}>
|
||||
{nodeCount.toLocaleString()} {t("statusBar.nodes")}
|
||||
</span>
|
||||
)}
|
||||
{edgeCount != null && (
|
||||
<span className={styles.stat}>
|
||||
{edgeCount.toLocaleString()} {t("statusBar.edges")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.right}>
|
||||
<span>
|
||||
{t("statusBar.lastRefresh")}{" "}
|
||||
{new Date().toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
frontend/src/components/graph/ControlsSidebar.module.css
Normal file
132
frontend/src/components/graph/ControlsSidebar.module.css
Normal file
@@ -0,0 +1,132 @@
|
||||
.sidebar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 220px;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
transition: width 0.2s;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.toggle:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.icons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md) 0;
|
||||
}
|
||||
|
||||
.sidebarIcon {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.slider {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.toggleList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.toggleItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
width: 100%;
|
||||
padding: 3px var(--space-xs);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--font-size-xs);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.toggleItem:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.toggleItem.enabled {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggleLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--text-muted);
|
||||
min-width: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.summary {
|
||||
padding-top: var(--space-xs);
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: var(--font-size-2xs);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
158
frontend/src/components/graph/ControlsSidebar.tsx
Normal file
158
frontend/src/components/graph/ControlsSidebar.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { memo, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronLeft, ChevronRight, Layers, GitFork, SlidersHorizontal } from "lucide-react";
|
||||
|
||||
import { dataColors, type DataEntityType, relationshipColors } from "@/styles/tokens";
|
||||
|
||||
import styles from "./ControlsSidebar.module.css";
|
||||
|
||||
interface ControlsSidebarProps {
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
depth: number;
|
||||
onDepthChange: (d: number) => void;
|
||||
enabledTypes: Set<string>;
|
||||
onToggleType: (t: string) => void;
|
||||
enabledRelTypes: Set<string>;
|
||||
onToggleRelType: (t: string) => void;
|
||||
typeCounts: Record<string, number>;
|
||||
relTypeCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
const ENTITY_TYPES = Object.keys(dataColors) as DataEntityType[];
|
||||
const REL_TYPES = Object.keys(relationshipColors);
|
||||
|
||||
function ControlsSidebarInner({
|
||||
collapsed,
|
||||
onToggle,
|
||||
depth,
|
||||
onDepthChange,
|
||||
enabledTypes,
|
||||
onToggleType,
|
||||
enabledRelTypes,
|
||||
onToggleRelType,
|
||||
typeCounts,
|
||||
relTypeCounts,
|
||||
}: ControlsSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleDepthChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onDepthChange(Number(e.target.value));
|
||||
},
|
||||
[onDepthChange],
|
||||
);
|
||||
|
||||
const visibleCount = Object.entries(typeCounts).reduce(
|
||||
(sum, [type, count]) => (enabledTypes.has(type) ? sum + count : sum),
|
||||
0,
|
||||
);
|
||||
const totalCount = Object.values(typeCounts).reduce((a, b) => a + b, 0);
|
||||
|
||||
return (
|
||||
<div className={`${styles.sidebar} ${collapsed ? styles.collapsed : ""}`}>
|
||||
<button className={styles.toggle} onClick={onToggle}>
|
||||
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
|
||||
</button>
|
||||
|
||||
{collapsed ? (
|
||||
<div className={styles.icons}>
|
||||
<span title={t("graph.depth")}><SlidersHorizontal size={16} className={styles.sidebarIcon} /></span>
|
||||
<span title={t("graph.entityTypes")}><Layers size={16} className={styles.sidebarIcon} /></span>
|
||||
<span title={t("graph.relationshipTypes")}><GitFork size={16} className={styles.sidebarIcon} /></span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.content}>
|
||||
{/* Depth slider */}
|
||||
<div className={styles.section}>
|
||||
<label className={styles.sectionLabel}>
|
||||
{t("graph.depth")}: {depth}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={4}
|
||||
value={depth}
|
||||
onChange={handleDepthChange}
|
||||
className={styles.slider}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Entity type toggles */}
|
||||
<div className={styles.section}>
|
||||
<label className={styles.sectionLabel}>
|
||||
{t("graph.entityTypes")}
|
||||
</label>
|
||||
<div className={styles.toggleList}>
|
||||
{ENTITY_TYPES.map((type) => {
|
||||
const count = typeCounts[type] ?? 0;
|
||||
const enabled = enabledTypes.has(type);
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
className={`${styles.toggleItem} ${enabled ? styles.enabled : ""}`}
|
||||
onClick={() => onToggleType(type)}
|
||||
>
|
||||
<span
|
||||
className={styles.dot}
|
||||
style={{
|
||||
backgroundColor: enabled
|
||||
? dataColors[type]
|
||||
: "var(--text-muted)",
|
||||
}}
|
||||
/>
|
||||
<span className={styles.toggleLabel}>
|
||||
{t(`entity.${type}`, type)}
|
||||
</span>
|
||||
<span className={styles.count}>{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Relationship type toggles */}
|
||||
<div className={styles.section}>
|
||||
<label className={styles.sectionLabel}>
|
||||
{t("graph.relationshipTypes")}
|
||||
</label>
|
||||
<div className={styles.toggleList}>
|
||||
{REL_TYPES.map((type) => {
|
||||
const count = relTypeCounts[type] ?? 0;
|
||||
const enabled = enabledRelTypes.has(type);
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
className={`${styles.toggleItem} ${enabled ? styles.enabled : ""}`}
|
||||
onClick={() => onToggleRelType(type)}
|
||||
>
|
||||
<span
|
||||
className={styles.dot}
|
||||
style={{
|
||||
backgroundColor: enabled
|
||||
? (relationshipColors[type] ?? "var(--text-muted)")
|
||||
: "var(--text-muted)",
|
||||
}}
|
||||
/>
|
||||
<span className={styles.toggleLabel}>{type}</span>
|
||||
<span className={styles.count}>{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter summary */}
|
||||
<div className={styles.summary}>
|
||||
{t("graph.filterSummary", {
|
||||
visible: visibleCount,
|
||||
total: totalCount,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ControlsSidebar = memo(ControlsSidebarInner);
|
||||
@@ -54,8 +54,8 @@ describe("SearchResults", () => {
|
||||
it("links to graph page for each result", () => {
|
||||
renderResults(sampleResults);
|
||||
const links = screen.getAllByRole("link");
|
||||
expect(links[0]).toHaveAttribute("href", "/graph/e1");
|
||||
expect(links[1]).toHaveAttribute("href", "/graph/e2");
|
||||
expect(links[0]).toHaveAttribute("href", "/app/analysis/e1");
|
||||
expect(links[1]).toHaveAttribute("href", "/app/analysis/e2");
|
||||
});
|
||||
|
||||
it("shows source badges", () => {
|
||||
|
||||
@@ -29,7 +29,7 @@ export function SearchResults({ results }: SearchResultsProps) {
|
||||
const color = entityColors[result.type as EntityType] ?? "var(--text-secondary)";
|
||||
return (
|
||||
<li key={result.id} className={styles.item}>
|
||||
<Link to={`/graph/${result.id}`} className={styles.link}>
|
||||
<Link to={`/app/analysis/${result.id}`} className={styles.link}>
|
||||
<span className={styles.typeBadge} style={{ borderColor: color, color }}>
|
||||
{t(`entity.${result.type}`, result.type)}
|
||||
</span>
|
||||
|
||||
48
frontend/src/hooks/useKeyboardShortcuts.ts
Normal file
48
frontend/src/hooks/useKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { getActions } from "@/actions/registry";
|
||||
|
||||
function matchShortcut(e: KeyboardEvent, shortcut: string): boolean {
|
||||
const parts = shortcut.toLowerCase().split("+");
|
||||
const needsMeta = parts.includes("cmd") || parts.includes("meta");
|
||||
const needsCtrl = parts.includes("ctrl");
|
||||
const needsShift = parts.includes("shift");
|
||||
const needsAlt = parts.includes("alt");
|
||||
const key = parts.filter(
|
||||
(p) => !["cmd", "meta", "ctrl", "shift", "alt"].includes(p),
|
||||
)[0];
|
||||
|
||||
if (needsMeta && !e.metaKey) return false;
|
||||
if (needsCtrl && !e.ctrlKey) return false;
|
||||
if (needsShift && !e.shiftKey) return false;
|
||||
if (needsAlt && !e.altKey) return false;
|
||||
if (!key) return false;
|
||||
if (e.key.toLowerCase() !== key) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts(): void {
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
const isInput = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
|
||||
const hasModifier = e.metaKey || e.ctrlKey;
|
||||
|
||||
// In input fields, only allow shortcuts with modifier keys (e.g. Cmd+K)
|
||||
if (isInput && !hasModifier) return;
|
||||
|
||||
const actions = getActions();
|
||||
for (const action of actions) {
|
||||
if (!action.shortcut) continue;
|
||||
if (matchShortcut(e, action.shortcut)) {
|
||||
e.preventDefault();
|
||||
action.handler();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
}
|
||||
@@ -6,15 +6,54 @@ const resources = {
|
||||
translation: {
|
||||
app: {
|
||||
title: "ICARUS",
|
||||
subtitle: "Ferramenta de análise de dados públicos brasileiros",
|
||||
subtitle: "Plataforma de inteligência em dados públicos brasileiros",
|
||||
disclaimer:
|
||||
"Dados de registros públicos. Não constitui acusação.",
|
||||
},
|
||||
landing: {
|
||||
hero: "Plataforma de inteligência em dados públicos brasileiros",
|
||||
cta: "Acessar plataforma",
|
||||
lastUpdated: "Dados atualizados em: {{date}}",
|
||||
stats: {
|
||||
entities: "entidades",
|
||||
connections: "conexões",
|
||||
dataSources: "fontes de dados",
|
||||
indexes: "índices",
|
||||
},
|
||||
features: {
|
||||
graph: "Análise de Grafo",
|
||||
graphDesc: "Mapeie conexões entre 56M de entidades em registros públicos",
|
||||
patterns: "Detecção de Padrões",
|
||||
patternsDesc: "5 padrões automatizados de análise de dados",
|
||||
investigations: "Área de Investigação",
|
||||
investigationsDesc: "Análise colaborativa com anotações e etiquetas",
|
||||
},
|
||||
sources: {
|
||||
title: "Fontes de Dados",
|
||||
cnpj: "Cadastro de empresas da Receita Federal",
|
||||
tse: "Doações eleitorais do TSE",
|
||||
transparencia: "Contratos do Portal da Transparência",
|
||||
sanctions: "Listas de sanções CEIS/CNEP",
|
||||
},
|
||||
footer: {
|
||||
methodology: "Metodologia",
|
||||
license: "AGPL-3.0",
|
||||
},
|
||||
},
|
||||
home: {
|
||||
tagline: "Mapeamento de conexões em dados públicos brasileiros",
|
||||
cta: "Iniciar busca",
|
||||
},
|
||||
dashboard: {
|
||||
welcome: "Painel",
|
||||
quickSearch: "Busca rápida",
|
||||
recentInvestigations: "Investigações recentes",
|
||||
continueInvestigation: "Continuar",
|
||||
noRecent: "Nenhuma investigação recente.",
|
||||
quickStats: "Estatísticas",
|
||||
},
|
||||
nav: {
|
||||
dashboard: "Painel",
|
||||
search: "Buscar",
|
||||
graph: "Grafo",
|
||||
patterns: "Padrões",
|
||||
@@ -22,12 +61,18 @@ const resources = {
|
||||
investigations: "Investigações",
|
||||
login: "Entrar",
|
||||
logout: "Sair",
|
||||
register: "Registrar",
|
||||
analysis: "Análise",
|
||||
theme: "Tema",
|
||||
themeLight: "Claro",
|
||||
themeDark: "Escuro",
|
||||
},
|
||||
auth: {
|
||||
login: "Entrar",
|
||||
register: "Registrar",
|
||||
email: "E-mail",
|
||||
password: "Senha",
|
||||
confirmPassword: "Confirmar senha",
|
||||
inviteCode: "Código de convite",
|
||||
switchToRegister: "Não tem conta? Registre-se",
|
||||
switchToLogin: "Já tem conta? Entre",
|
||||
@@ -35,6 +80,9 @@ const resources = {
|
||||
registerError: "Erro ao registrar. Tente novamente.",
|
||||
invalidCredentials: "E-mail ou senha incorretos.",
|
||||
invalidInvite: "Código de convite inválido.",
|
||||
loginTitle: "Acessar ICARUS",
|
||||
registerTitle: "Criar conta",
|
||||
loginSubtitle: "Plataforma de inteligência em dados públicos",
|
||||
},
|
||||
search: {
|
||||
placeholder: "CPF, CNPJ ou nome...",
|
||||
@@ -44,6 +92,9 @@ const resources = {
|
||||
typeAll: "Todos",
|
||||
error: "Erro ao buscar. Tente novamente.",
|
||||
emptyHint: "Tente buscar por nome, CPF ou CNPJ",
|
||||
recentSearches: "Buscas recentes",
|
||||
clearRecent: "Limpar",
|
||||
resultCount: "{{count}} resultados",
|
||||
},
|
||||
entity: {
|
||||
person: "Pessoa",
|
||||
@@ -52,6 +103,7 @@ const resources = {
|
||||
finance: "Financeiro",
|
||||
sanction: "Sanção",
|
||||
election: "Eleição",
|
||||
amendment: "Emenda",
|
||||
publicOffice: "Cargo Público",
|
||||
legal: "Jurídico",
|
||||
health: "Saúde",
|
||||
@@ -65,7 +117,22 @@ const resources = {
|
||||
graph: {
|
||||
depth: "Profundidade",
|
||||
entityTypes: "Tipos de entidade",
|
||||
relationshipTypes: "Tipos de conexão",
|
||||
noData: "Nenhum dado de grafo disponível.",
|
||||
searchInGraph: "Buscar no grafo...",
|
||||
layoutForce: "Força",
|
||||
layoutHierarchy: "Hierarquia",
|
||||
fullscreen: "Tela cheia",
|
||||
exportPng: "Exportar PNG",
|
||||
fitView: "Ajustar",
|
||||
zoomIn: "Ampliar",
|
||||
zoomOut: "Reduzir",
|
||||
resetZoom: "Redefinir zoom",
|
||||
filterSummary: "{{visible}} de {{total}} nós",
|
||||
expand: "Expandir nó",
|
||||
hide: "Ocultar nó",
|
||||
viewDetail: "Ver detalhes",
|
||||
addToInvestigation: "Adicionar à investigação",
|
||||
edge: {
|
||||
type: "Tipo de conexão",
|
||||
source: "Origem",
|
||||
@@ -75,6 +142,10 @@ const resources = {
|
||||
sources: "Fontes",
|
||||
noValue: "Sem valor monetário",
|
||||
},
|
||||
legend: {
|
||||
title: "Legenda",
|
||||
},
|
||||
minimap: "Minimapa",
|
||||
},
|
||||
patterns: {
|
||||
title: "Padrões de Análise",
|
||||
@@ -123,12 +194,99 @@ const resources = {
|
||||
noData: "Sem dados de comparação.",
|
||||
peers: "{{count}} pares",
|
||||
},
|
||||
analysis: {
|
||||
title: "Análise de Entidade",
|
||||
graph: "Grafo",
|
||||
connections: "Conexões",
|
||||
timeline: "Linha do tempo",
|
||||
export: "Exportar",
|
||||
insights: "Análise",
|
||||
detail: "Detalhes",
|
||||
addToInvestigation: "Adicionar à investigação",
|
||||
selectInvestigation: "Selecionar investigação",
|
||||
createNew: "Criar nova",
|
||||
addNote: "Adicionar nota",
|
||||
noPatterns: "Nenhum padrão encontrado.",
|
||||
noTimeline: "Nenhum evento no período.",
|
||||
loadMore: "Carregar mais",
|
||||
exposure: "Índice de exposição",
|
||||
exposureDesc: "Métrica composta de conectividade e volume financeiro",
|
||||
factor: {
|
||||
connections: "Conexões",
|
||||
sources: "Fontes cruzadas",
|
||||
financial: "Volume financeiro",
|
||||
patterns: "Padrões detectados",
|
||||
baseline: "Desvio da linha base",
|
||||
},
|
||||
peerGroup: "Grupo de pares",
|
||||
peers: "{{count}} pares",
|
||||
percentile: "Percentil {{value}}",
|
||||
exportPdf: "Relatório PDF",
|
||||
exportCsv: "Planilha CSV",
|
||||
exportJson: "Dados JSON",
|
||||
exportScreenshot: "Captura de tela",
|
||||
recentAnalyses: "Análises recentes",
|
||||
patternScanner: "Busca por padrão",
|
||||
noExposure: "Dados insuficientes para calcular exposição.",
|
||||
},
|
||||
command: {
|
||||
placeholder: "Digite um comando...",
|
||||
noResults: "Nenhum comando encontrado.",
|
||||
navigation: "Navegação",
|
||||
actions: "Ações",
|
||||
goToDashboard: "Ir para o Painel",
|
||||
goToSearch: "Ir para Busca",
|
||||
goToPatterns: "Ir para Padrões",
|
||||
goToInvestigations: "Ir para Investigações",
|
||||
toggleFullscreen: "Alternar tela cheia",
|
||||
toggleSidebar: "Alternar barra lateral",
|
||||
showShortcuts: "Mostrar atalhos",
|
||||
},
|
||||
shortcuts: {
|
||||
title: "Atalhos de Teclado",
|
||||
navigation: "Navegação",
|
||||
graph: "Grafo",
|
||||
general: "Geral",
|
||||
commandPalette: "Paleta de comandos",
|
||||
search: "Buscar",
|
||||
close: "Fechar",
|
||||
},
|
||||
statusBar: {
|
||||
connected: "Conectado",
|
||||
disconnected: "Desconectado",
|
||||
nodes: "nós",
|
||||
edges: "arestas",
|
||||
lastRefresh: "Atualizado",
|
||||
},
|
||||
toast: {
|
||||
copied: "Copiado!",
|
||||
saved: "Salvo!",
|
||||
deleted: "Excluído!",
|
||||
error: "Erro ao executar ação.",
|
||||
},
|
||||
mobile: {
|
||||
title: "ICARUS",
|
||||
message: "Esta plataforma requer resolução mínima de 1024px para análise de grafos.",
|
||||
hint: "Acesse em um computador para a experiência completa.",
|
||||
},
|
||||
common: {
|
||||
source: "Fonte",
|
||||
confidence: "Confiança",
|
||||
connections: "conexões",
|
||||
sources: "fontes",
|
||||
loading: "Carregando...",
|
||||
cancel: "Cancelar",
|
||||
confirm: "Confirmar",
|
||||
close: "Fechar",
|
||||
save: "Salvar",
|
||||
delete: "Excluir",
|
||||
edit: "Editar",
|
||||
back: "Voltar",
|
||||
},
|
||||
error: {
|
||||
title: "ICARUS",
|
||||
message: "Algo deu errado. Recarregue a página.",
|
||||
reload: "Recarregar",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -136,15 +294,54 @@ const resources = {
|
||||
translation: {
|
||||
app: {
|
||||
title: "ICARUS",
|
||||
subtitle: "Brazilian public data analysis tool",
|
||||
subtitle: "Brazilian public data intelligence platform",
|
||||
disclaimer:
|
||||
"Data patterns from public records. Not accusations.",
|
||||
},
|
||||
landing: {
|
||||
hero: "Brazilian public data intelligence platform",
|
||||
cta: "Access platform",
|
||||
lastUpdated: "Data updated: {{date}}",
|
||||
stats: {
|
||||
entities: "entities",
|
||||
connections: "connections",
|
||||
dataSources: "data sources",
|
||||
indexes: "indexes",
|
||||
},
|
||||
features: {
|
||||
graph: "Graph Analysis",
|
||||
graphDesc: "Map connections across 56M entities in public records",
|
||||
patterns: "Pattern Detection",
|
||||
patternsDesc: "5 automated data analysis patterns",
|
||||
investigations: "Investigation Workspace",
|
||||
investigationsDesc: "Collaborative analysis with annotations and tags",
|
||||
},
|
||||
sources: {
|
||||
title: "Data Sources",
|
||||
cnpj: "Federal Revenue company registry",
|
||||
tse: "Electoral Court donation records",
|
||||
transparencia: "Government transparency portal contracts",
|
||||
sanctions: "CEIS/CNEP sanctions lists",
|
||||
},
|
||||
footer: {
|
||||
methodology: "Methodology",
|
||||
license: "AGPL-3.0",
|
||||
},
|
||||
},
|
||||
home: {
|
||||
tagline: "Mapping connections in Brazilian public data",
|
||||
cta: "Start searching",
|
||||
},
|
||||
dashboard: {
|
||||
welcome: "Dashboard",
|
||||
quickSearch: "Quick search",
|
||||
recentInvestigations: "Recent investigations",
|
||||
continueInvestigation: "Continue",
|
||||
noRecent: "No recent investigations.",
|
||||
quickStats: "Statistics",
|
||||
},
|
||||
nav: {
|
||||
dashboard: "Dashboard",
|
||||
search: "Search",
|
||||
graph: "Graph",
|
||||
patterns: "Patterns",
|
||||
@@ -152,12 +349,18 @@ const resources = {
|
||||
investigations: "Investigations",
|
||||
login: "Login",
|
||||
logout: "Logout",
|
||||
register: "Register",
|
||||
analysis: "Analysis",
|
||||
theme: "Theme",
|
||||
themeLight: "Light",
|
||||
themeDark: "Dark",
|
||||
},
|
||||
auth: {
|
||||
login: "Login",
|
||||
register: "Register",
|
||||
email: "Email",
|
||||
password: "Password",
|
||||
confirmPassword: "Confirm password",
|
||||
inviteCode: "Invite code",
|
||||
switchToRegister: "No account? Register",
|
||||
switchToLogin: "Have an account? Login",
|
||||
@@ -165,6 +368,9 @@ const resources = {
|
||||
registerError: "Registration failed. Please try again.",
|
||||
invalidCredentials: "Invalid email or password.",
|
||||
invalidInvite: "Invalid invite code.",
|
||||
loginTitle: "Access ICARUS",
|
||||
registerTitle: "Create account",
|
||||
loginSubtitle: "Public data intelligence platform",
|
||||
},
|
||||
search: {
|
||||
placeholder: "CPF, CNPJ, or name...",
|
||||
@@ -174,6 +380,9 @@ const resources = {
|
||||
typeAll: "All",
|
||||
error: "Search failed. Please try again.",
|
||||
emptyHint: "Try searching by name, CPF, or CNPJ",
|
||||
recentSearches: "Recent searches",
|
||||
clearRecent: "Clear",
|
||||
resultCount: "{{count}} results",
|
||||
},
|
||||
entity: {
|
||||
person: "Person",
|
||||
@@ -182,6 +391,7 @@ const resources = {
|
||||
finance: "Finance",
|
||||
sanction: "Sanction",
|
||||
election: "Election",
|
||||
amendment: "Amendment",
|
||||
publicOffice: "Public Office",
|
||||
legal: "Legal",
|
||||
health: "Health",
|
||||
@@ -195,7 +405,22 @@ const resources = {
|
||||
graph: {
|
||||
depth: "Depth",
|
||||
entityTypes: "Entity types",
|
||||
relationshipTypes: "Relationship types",
|
||||
noData: "No graph data available.",
|
||||
searchInGraph: "Search in graph...",
|
||||
layoutForce: "Force",
|
||||
layoutHierarchy: "Hierarchy",
|
||||
fullscreen: "Fullscreen",
|
||||
exportPng: "Export PNG",
|
||||
fitView: "Fit view",
|
||||
zoomIn: "Zoom in",
|
||||
zoomOut: "Zoom out",
|
||||
resetZoom: "Reset zoom",
|
||||
filterSummary: "{{visible}} of {{total}} nodes",
|
||||
expand: "Expand node",
|
||||
hide: "Hide node",
|
||||
viewDetail: "View detail",
|
||||
addToInvestigation: "Add to investigation",
|
||||
edge: {
|
||||
type: "Connection Type",
|
||||
source: "Source",
|
||||
@@ -205,6 +430,10 @@ const resources = {
|
||||
sources: "Sources",
|
||||
noValue: "No monetary value",
|
||||
},
|
||||
legend: {
|
||||
title: "Legend",
|
||||
},
|
||||
minimap: "Minimap",
|
||||
},
|
||||
patterns: {
|
||||
title: "Analysis Patterns",
|
||||
@@ -253,12 +482,99 @@ const resources = {
|
||||
noData: "No comparison data.",
|
||||
peers: "{{count}} peers",
|
||||
},
|
||||
analysis: {
|
||||
title: "Entity Analysis",
|
||||
graph: "Graph",
|
||||
connections: "Connections",
|
||||
timeline: "Timeline",
|
||||
export: "Export",
|
||||
insights: "Insights",
|
||||
detail: "Detail",
|
||||
addToInvestigation: "Add to investigation",
|
||||
selectInvestigation: "Select investigation",
|
||||
createNew: "Create new",
|
||||
addNote: "Add note",
|
||||
noPatterns: "No patterns found.",
|
||||
noTimeline: "No events in this period.",
|
||||
loadMore: "Load more",
|
||||
exposure: "Exposure index",
|
||||
exposureDesc: "Composite metric of connectivity and financial volume",
|
||||
factor: {
|
||||
connections: "Connections",
|
||||
sources: "Cross-source",
|
||||
financial: "Financial volume",
|
||||
patterns: "Patterns detected",
|
||||
baseline: "Baseline deviation",
|
||||
},
|
||||
peerGroup: "Peer group",
|
||||
peers: "{{count}} peers",
|
||||
percentile: "Percentile {{value}}",
|
||||
exportPdf: "PDF Report",
|
||||
exportCsv: "CSV Spreadsheet",
|
||||
exportJson: "JSON Data",
|
||||
exportScreenshot: "Screenshot",
|
||||
recentAnalyses: "Recent analyses",
|
||||
patternScanner: "Pattern search",
|
||||
noExposure: "Insufficient data to calculate exposure.",
|
||||
},
|
||||
command: {
|
||||
placeholder: "Type a command...",
|
||||
noResults: "No commands found.",
|
||||
navigation: "Navigation",
|
||||
actions: "Actions",
|
||||
goToDashboard: "Go to Dashboard",
|
||||
goToSearch: "Go to Search",
|
||||
goToPatterns: "Go to Patterns",
|
||||
goToInvestigations: "Go to Investigations",
|
||||
toggleFullscreen: "Toggle fullscreen",
|
||||
toggleSidebar: "Toggle sidebar",
|
||||
showShortcuts: "Show shortcuts",
|
||||
},
|
||||
shortcuts: {
|
||||
title: "Keyboard Shortcuts",
|
||||
navigation: "Navigation",
|
||||
graph: "Graph",
|
||||
general: "General",
|
||||
commandPalette: "Command palette",
|
||||
search: "Search",
|
||||
close: "Close",
|
||||
},
|
||||
statusBar: {
|
||||
connected: "Connected",
|
||||
disconnected: "Disconnected",
|
||||
nodes: "nodes",
|
||||
edges: "edges",
|
||||
lastRefresh: "Updated",
|
||||
},
|
||||
toast: {
|
||||
copied: "Copied!",
|
||||
saved: "Saved!",
|
||||
deleted: "Deleted!",
|
||||
error: "Action failed.",
|
||||
},
|
||||
mobile: {
|
||||
title: "ICARUS",
|
||||
message: "This platform requires a minimum resolution of 1024px for graph analysis.",
|
||||
hint: "Access from a computer for the full experience.",
|
||||
},
|
||||
common: {
|
||||
source: "Source",
|
||||
confidence: "Confidence",
|
||||
connections: "connections",
|
||||
sources: "sources",
|
||||
loading: "Loading...",
|
||||
cancel: "Cancel",
|
||||
confirm: "Confirm",
|
||||
close: "Close",
|
||||
save: "Save",
|
||||
delete: "Delete",
|
||||
edit: "Edit",
|
||||
back: "Back",
|
||||
},
|
||||
error: {
|
||||
title: "ICARUS",
|
||||
message: "Something went wrong. Please reload the page.",
|
||||
reload: "Reload",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
111
frontend/src/pages/Dashboard.tsx
Normal file
111
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
|
||||
import { type Investigation, listInvestigations, searchEntities, type SearchResult } from "@/api/client";
|
||||
import { Skeleton } from "@/components/common/Skeleton";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
import styles from "./Dashboard.module.css";
|
||||
|
||||
export function Dashboard() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const addToast = useToastStore((s) => s.addToast);
|
||||
|
||||
const [recentInvestigations, setRecentInvestigations] = useState<Investigation[]>([]);
|
||||
const [loadingInvestigations, setLoadingInvestigations] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
listInvestigations(1, 3)
|
||||
.then((res) => setRecentInvestigations(res.investigations))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoadingInvestigations(false));
|
||||
}, []);
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const q = searchQuery.trim();
|
||||
if (!q) return;
|
||||
setSearching(true);
|
||||
try {
|
||||
const res = await searchEntities(q, undefined, 1, 5);
|
||||
setSearchResults(res.results);
|
||||
} catch {
|
||||
setSearchResults([]);
|
||||
addToast("error", t("search.error"));
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<h1 className={styles.title}>{t("dashboard.welcome")}</h1>
|
||||
|
||||
<section className={styles.searchSection}>
|
||||
<h2 className={styles.sectionTitle}>{t("dashboard.quickSearch")}</h2>
|
||||
<form className={styles.searchForm} onSubmit={handleSearch}>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.searchInput}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t("search.placeholder")}
|
||||
/>
|
||||
<button type="submit" className={styles.searchBtn} disabled={searching}>
|
||||
{t("search.button")}
|
||||
</button>
|
||||
</form>
|
||||
{searchResults.length > 0 && (
|
||||
<ul className={styles.quickResults}>
|
||||
{searchResults.map((r) => (
|
||||
<li key={r.id}>
|
||||
<Link to={`/app/analysis/${r.id}`} className={styles.quickResultLink}>
|
||||
<span className={styles.quickResultType}>
|
||||
{t(`entity.${r.type}`, r.type)}
|
||||
</span>
|
||||
<span className={styles.quickResultName}>{r.name}</span>
|
||||
{r.document && (
|
||||
<span className={styles.quickResultDoc}>{r.document}</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={styles.investigationsSection}>
|
||||
<h2 className={styles.sectionTitle}>{t("dashboard.recentInvestigations")}</h2>
|
||||
{loadingInvestigations ? (
|
||||
<div className={styles.skeletons}>
|
||||
<Skeleton variant="rect" height="72px" />
|
||||
<Skeleton variant="rect" height="72px" />
|
||||
<Skeleton variant="rect" height="72px" />
|
||||
</div>
|
||||
) : recentInvestigations.length === 0 ? (
|
||||
<p className={styles.empty}>{t("dashboard.noRecent")}</p>
|
||||
) : (
|
||||
<div className={styles.investigationCards}>
|
||||
{recentInvestigations.map((inv) => (
|
||||
<button
|
||||
key={inv.id}
|
||||
className={styles.investigationCard}
|
||||
onClick={() => navigate(`/app/investigations/${inv.id}`)}
|
||||
>
|
||||
<span className={styles.invTitle}>{inv.title}</span>
|
||||
<span className={styles.invMeta}>
|
||||
{inv.entity_ids.length} {t("common.connections")} · {new Date(inv.updated_at).toLocaleDateString()}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
frontend/src/pages/EntityAnalysis.tsx
Normal file
14
frontend/src/pages/EntityAnalysis.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
export function EntityAnalysis() {
|
||||
const { t } = useTranslation();
|
||||
const { entityId } = useParams<{ entityId: string }>();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{t("analysis.title")}</h1>
|
||||
<p>{entityId}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export function Patterns() {
|
||||
|
||||
const handleEntityClick = useCallback(
|
||||
(id: string) => {
|
||||
navigate(`/graph/${encodeURIComponent(id)}`);
|
||||
navigate(`/app/analysis/${encodeURIComponent(id)}`);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
@@ -1,64 +1,185 @@
|
||||
/* === IBM Plex Sans — UI chrome === */
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/fonts/IBMPlexSans-Regular.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("/fonts/IBMPlexSans-Medium.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("/fonts/IBMPlexSans-SemiBold.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("/fonts/IBMPlexSans-Bold.woff2") format("woff2");
|
||||
}
|
||||
|
||||
/* === IBM Plex Mono — data display === */
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/fonts/IBMPlexMono-Regular.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("/fonts/IBMPlexMono-Medium.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("/fonts/IBMPlexMono-SemiBold.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("/fonts/IBMPlexMono-Bold.woff2") format("woff2");
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Background */
|
||||
--bg-primary: #0d100e;
|
||||
--bg-secondary: #141a16;
|
||||
--bg-tertiary: #1a211c;
|
||||
/* === Backgrounds — deep black-green, night-vision base === */
|
||||
--bg-primary: #060a07;
|
||||
--bg-secondary: #0c1210;
|
||||
--bg-tertiary: #111a16;
|
||||
--bg-elevated: #162018;
|
||||
--bg-surface: #1a2820;
|
||||
--bg-overlay: rgba(0, 0, 0, 0.75);
|
||||
|
||||
/* Text */
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #8a8a8a;
|
||||
--text-muted: #555;
|
||||
/* === Text — high contrast for data readability === */
|
||||
--text-primary: #e8ede9;
|
||||
--text-secondary: #94a39a;
|
||||
--text-muted: #5a6b60;
|
||||
|
||||
/* Accent */
|
||||
--accent: #e07a2f;
|
||||
--accent-hover: #f08a3f;
|
||||
/* === Accent — amber/gold (WCAG AA: 5.8:1 on bg-primary) === */
|
||||
--accent: #ff9a3c;
|
||||
--accent-hover: #ffb060;
|
||||
--accent-dim: rgba(255, 154, 60, 0.15);
|
||||
|
||||
/* Border */
|
||||
/* === System — cyan === */
|
||||
--cyan: #00e5c3;
|
||||
--cyan-dim: rgba(0, 229, 195, 0.1);
|
||||
--cyan-glow: 0 0 12px rgba(0, 229, 195, 0.3);
|
||||
|
||||
/* === Semantic === */
|
||||
--color-success: #22c55e;
|
||||
--color-warning: #eab308;
|
||||
--color-danger: #ef4444;
|
||||
--color-info: #3b82f6;
|
||||
|
||||
/* === Data categorical — 6 colorblind-safe graph colors === */
|
||||
--data-person: #4ea8de;
|
||||
--data-company: #e07a5f;
|
||||
--data-election: #81b29a;
|
||||
--data-contract: #f2cc8f;
|
||||
--data-sanction: #e56b6f;
|
||||
--data-amendment: #b8a9c9;
|
||||
|
||||
/* === Borders === */
|
||||
--border: rgba(255, 255, 255, 0.04);
|
||||
--border-hover: rgba(255, 255, 255, 0.1);
|
||||
--border-subtle: rgba(255, 255, 255, 0.02);
|
||||
--border-hover: rgba(255, 255, 255, 0.08);
|
||||
--border-active: rgba(0, 229, 195, 0.2);
|
||||
|
||||
/* Entity type colors */
|
||||
--color-person: #e07a2f;
|
||||
--color-company: #dd6b20;
|
||||
--color-contract: #38a169;
|
||||
--color-finance: #3b82f6;
|
||||
--color-sanction: #dc2626;
|
||||
--color-legal: #ca8a04;
|
||||
--color-health: #ec4899;
|
||||
--color-environment: #0d9488;
|
||||
--color-labor: #64748b;
|
||||
--color-education: #a855f7;
|
||||
--color-regulatory: #2dd4bf;
|
||||
--color-property: #8b5cf6;
|
||||
/* === Shadows === */
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
--shadow-glow: 0 0 20px rgba(255, 154, 60, 0.1);
|
||||
|
||||
/* Spacing */
|
||||
/* === Spacing === */
|
||||
--space-2xs: 2px;
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 16px;
|
||||
--space-lg: 24px;
|
||||
--space-xl: 32px;
|
||||
--space-2xl: 48px;
|
||||
--space-3xl: 64px;
|
||||
--space-4xl: 96px;
|
||||
|
||||
/* Typography */
|
||||
--font-mono: "JetBrains Mono", "Fira Code", "Cascadia Code", monospace;
|
||||
/* === Typography === */
|
||||
--font-sans: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--font-mono: "IBM Plex Mono", "Menlo", "Consolas", monospace;
|
||||
--font-size-2xs: 0.625rem;
|
||||
--font-size-xs: 0.75rem;
|
||||
--font-size-sm: 0.875rem;
|
||||
--font-size-md: 1rem;
|
||||
--font-size-lg: 1.25rem;
|
||||
--font-size-xl: 1.5rem;
|
||||
--font-size-2xl: 2rem;
|
||||
--font-size-3xl: 3rem;
|
||||
--font-size-4xl: 4rem;
|
||||
|
||||
/* Radius */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
/* === Radius — intelligence aesthetic: 2-4px max === */
|
||||
--radius-sm: 2px;
|
||||
--radius-md: 4px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-normal: 250ms ease;
|
||||
/* === Transitions === */
|
||||
--transition-fast: 150ms ease-out;
|
||||
--transition-normal: 250ms ease-out;
|
||||
|
||||
/* Focus */
|
||||
/* === Focus === */
|
||||
--focus-ring: 0 0 0 2px var(--accent);
|
||||
|
||||
/* Hover */
|
||||
/* === Hover === */
|
||||
--bg-hover: rgba(255, 255, 255, 0.03);
|
||||
|
||||
/* === Z-index scale === */
|
||||
--z-dropdown: 100;
|
||||
--z-sticky: 200;
|
||||
--z-overlay: 300;
|
||||
--z-modal: 400;
|
||||
--z-toast: 500;
|
||||
--z-command: 600;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #f5f7f6;
|
||||
--bg-secondary: #edf0ee;
|
||||
--bg-tertiary: #e4e8e5;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-overlay: rgba(0, 0, 0, 0.3);
|
||||
--text-primary: #1a2820;
|
||||
--text-secondary: #4a5c52;
|
||||
--text-muted: #7a8c82;
|
||||
--accent: #d97706;
|
||||
--accent-hover: #b45309;
|
||||
--accent-dim: rgba(217, 119, 6, 0.1);
|
||||
--cyan: #0891b2;
|
||||
--cyan-dim: rgba(8, 145, 178, 0.08);
|
||||
--cyan-glow: 0 0 12px rgba(8, 145, 178, 0.15);
|
||||
--border: rgba(0, 0, 0, 0.08);
|
||||
--border-subtle: rgba(0, 0, 0, 0.04);
|
||||
--border-hover: rgba(0, 0, 0, 0.12);
|
||||
--border-active: rgba(8, 145, 178, 0.2);
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||
--shadow-glow: 0 0 20px rgba(217, 119, 6, 0.05);
|
||||
--bg-hover: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -67,12 +188,27 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.6;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
code,
|
||||
pre,
|
||||
kbd,
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-feature-settings: "tnum" 1, "zero" 1;
|
||||
font-variant-numeric: tabular-nums slashed-zero;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -97,3 +233,22 @@ textarea:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* === Reduced motion === */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user