diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 62b7dd9..a6d2b8a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,17 +2,17 @@ name: CI
on:
push:
- branches: [ main, master ]
+ branches: [main, master]
pull_request:
- branches: [ "**" ]
+ branches: ["**"]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
- NODE_VERSION: '20'
- PNPM_VERSION: '8'
+ NODE_VERSION: "20"
+ PNPM_VERSION: "8"
jobs:
lint:
@@ -231,12 +231,18 @@ jobs:
secrets_scan:
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: write
steps:
- uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
- name: Gitleaks
uses: gitleaks/gitleaks-action@v2
- with:
- args: detect --redact --no-banner
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GITLEAKS_VERSION: 8.24.3
build:
runs-on: ubuntu-latest
diff --git a/.gitignore b/.gitignore
index ca31811..6241a42 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,6 @@ dist/
build/
.vercel/
.cache/
+
+# Build
+apps/web/.next/
\ No newline at end of file
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
new file mode 100644
index 0000000..18323ca
--- /dev/null
+++ b/apps/web/app/globals.css
@@ -0,0 +1,68 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 0 0% 3.9%;
+ --card: 0 0% 100%;
+ --card-foreground: 0 0% 3.9%;
+ --popover: 0 0% 100%;
+ --popover-foreground: 0 0% 3.9%;
+ --primary: 0 0% 9%;
+ --primary-foreground: 0 0% 98%;
+ --secondary: 0 0% 96.1%;
+ --secondary-foreground: 0 0% 9%;
+ --muted: 0 0% 96.1%;
+ --muted-foreground: 0 0% 45.1%;
+ --accent: 0 0% 96.1%;
+ --accent-foreground: 0 0% 9%;
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 0 0% 89.8%;
+ --input: 0 0% 89.8%;
+ --ring: 0 0% 3.9%;
+ --chart-1: 12 76% 61%;
+ --chart-2: 173 58% 39%;
+ --chart-3: 197 37% 24%;
+ --chart-4: 43 74% 66%;
+ --chart-5: 27 87% 67%;
+ --radius: 0.5rem
+ }
+ .dark {
+ --background: 0 0% 3.9%;
+ --foreground: 0 0% 98%;
+ --card: 0 0% 3.9%;
+ --card-foreground: 0 0% 98%;
+ --popover: 0 0% 3.9%;
+ --popover-foreground: 0 0% 98%;
+ --primary: 0 0% 98%;
+ --primary-foreground: 0 0% 9%;
+ --secondary: 0 0% 14.9%;
+ --secondary-foreground: 0 0% 98%;
+ --muted: 0 0% 14.9%;
+ --muted-foreground: 0 0% 63.9%;
+ --accent: 0 0% 14.9%;
+ --accent-foreground: 0 0% 98%;
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 0 0% 14.9%;
+ --input: 0 0% 14.9%;
+ --ring: 0 0% 83.1%;
+ --chart-1: 220 70% 50%;
+ --chart-2: 160 60% 45%;
+ --chart-3: 30 80% 55%;
+ --chart-4: 280 65% 60%;
+ --chart-5: 340 75% 55%
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 5a083cb..028fc71 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -1,30 +1,16 @@
-import type { ReactNode } from 'react';
-import Link from 'next/link';
-
+import { AppThemeProvider } from "@/components/theme-provider";
+import type { RootLayoutProps } from "../types";
export const metadata = {
- title: 'AliasMap',
- description: 'OSINT username mapping with ethical, public-only collection.'
+ title: "AliasMap",
+ description: "OSINT username mapping with ethical, public-only collection.",
};
-export default function RootLayout({ children }: { children: ReactNode }) {
+export default function RootLayout({ children }: RootLayoutProps) {
return (
-
-
-
-
- {children}
-
+
+ {children}
);
}
-
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 5a99e84..dfc1d67 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -1,159 +1,185 @@
-'use client';
-
-import { useMemo, useState } from 'react';
-import Link from 'next/link';
-import { EthicsBanner } from '../components/EthicsBanner';
-import { buildMindmap } from '../lib/mindmap';
-import { platformLabel } from '../lib/platforms';
-import { MindmapPreview } from '../components/MindmapPreview';
-
-type SiteEvent =
- | { type: 'site_start'; id: string }
- | { type: 'site_result'; id: string; status: string; url?: string; latencyMs?: number; reason?: string; evidence?: { kind: string; value: string }[]; heuristic?: boolean }
- | { type: 'site_error'; id: string; reason: string }
- | { type: 'progress'; done: number; total: number }
- | { type: 'done'; summary: { done: number; total: number } };
-
-export default function Home() {
- const [username, setUsername] = useState('');
- const [events, setEvents] = useState([]);
- const [running, setRunning] = useState(false);
- // Show only truly found; do not coerce 'inconclusive' to 'found'
-
- const canStart = useMemo(() => {
- return !running && username.trim().length > 0;
- }, [running, username]);
-
- const identityStatus = (s: string) => s;
-
- // Progress is intentionally hidden from the UI per request.
-
- const latestByPlatform = useMemo(() => {
- type SiteResultEvent = Extract;
- type UiResult = (SiteResultEvent & { platform: string; rawStatus: SiteResultEvent['status']; heuristic?: boolean }) & {
- status: 'found' | 'not_found' | 'inconclusive' | string;
- };
- const map = new Map();
+"use client";
+
+import * as React from "react";
+import Image from "next/image";
+import logo from "./icon.svg";
+import { Box, Container, Paper, Stack, Typography, IconButton, Tooltip } from "@mui/material";
+import RestartAltIcon from "@mui/icons-material/RestartAlt";
+import GavelIcon from "@mui/icons-material/Gavel";
+import SearchScreen from "@/components/SearchScreen";
+import Loading from "@/components/LoadingScreen";
+import ResultsScreen from "@/components/ResultsScreen";
+import EthicsPage from "@/components/EthicsPage";
+import type { SiteEvent, Status, MindmapItem } from "../types";
+
+const normalizeStatus = (s: string): Status => {
+ const v = s.toLowerCase();
+ if (v.includes("found") || v === "200") return "found";
+ if (v.includes("not") || v === "404") return "not_found";
+ if (v.includes("timeout") || v.includes("error")) return "inconclusive";
+ return s as Status;
+};
+
+export default function HomeStepsSimple() {
+ const [step, setStep] = React.useState<0 | 1 | 2 | 3>(0);
+ const [username, setUsername] = React.useState("");
+ const [running, setRunning] = React.useState(false);
+ const [error, setError] = React.useState(null);
+ const [events, setEvents] = React.useState([]);
+ const [progress, setProgress] = React.useState<{ done: number; total: number }>({
+ done: 0,
+ total: 0,
+ });
+ const esRef = React.useRef(null);
+
+ const foundOnly: MindmapItem[] = React.useMemo(() => {
+ const latestByPlatform = new Map>();
for (const e of events) {
- if (e.type === 'site_result') {
- const label = platformLabel(e.id);
- map.set(e.id, { ...e, rawStatus: e.status, status: identityStatus(e.status), platform: label });
- }
+ if (e.type === "site_result") latestByPlatform.set(e.id, e);
}
- return Array.from(map.values());
+ return Array.from(latestByPlatform.values())
+ .filter((e) => normalizeStatus(e.status) === "found")
+ .map((e) => ({
+ platform: e.id,
+ status: "found" as Status,
+ rawStatus: e.status as Status,
+ heuristic: e.heuristic,
+ url: e.url,
+ }));
}, [events]);
- const startScan = () => {
- if (!username) return;
+ const hasData = foundOnly.length > 0;
+
+ const startScan = (name: string) => {
+ if (!name) return;
+ const isValid = /^[a-zA-Z0-9._-]{3,32}$/.test(name);
+ if (!isValid) {
+ setError("Username inválido. Use 3–32 caracteres: letras, números, ponto, traço e sublinhado.");
+ return;
+ }
+ const normalizedUsername = name.trim().toLowerCase();
+ setUsername(normalizedUsername);
+
+ if (esRef.current) {
+ esRef.current.close();
+ esRef.current = null;
+ }
+ setStep(1);
setEvents([]);
+ setError(null);
+ setProgress({ done: 0, total: 0 });
setRunning(true);
- const es = new EventSource(`/api/scan?username=${encodeURIComponent(username)}&tier=all`);
+
+ const es = new EventSource(`/api/scan?username=${encodeURIComponent(normalizedUsername)}&tier=all`);
+ esRef.current = es;
es.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data) as SiteEvent;
- setEvents(prev => [...prev, data]);
- if (data.type === 'done') {
+ setEvents((prev) => [...prev, data]);
+ if (data.type === "progress") {
+ setProgress({ done: data.done, total: data.total });
+ }
+
+ if (data.type === "done") {
es.close();
+ esRef.current = null;
setRunning(false);
+ setStep(2);
}
- } catch (e) {
- // ignore parse errors in stub
+ } catch (error) {
+ console.error("Erro ao processar evento SSE:", error);
}
};
es.onerror = () => {
es.close();
+ esRef.current = null;
setRunning(false);
+ setError("Falha na conexão de varredura (SSE). Tente novamente.");
};
};
- const exportJson = () => {
- const mindmap = buildMindmap(username, events);
- const header = {
- tool: 'AliasMap',
- note:
- 'Dados coletados de fontes públicas. Uso responsável. Nenhuma persistência no servidor. Consulte o Aviso e Uso Ético.'
- };
- const payload = { header, username, events, mindmap };
- const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = `aliasmap-${username || 'resultado'}.json`;
- a.click();
- URL.revokeObjectURL(url);
+ const resetAll = () => {
+ setStep(0);
+ setUsername("");
+ setEvents([]);
+ setError(null);
+ setProgress({ done: 0, total: 0 });
+ setRunning(false);
};
- const exportCsv = () => {
- const header = 'tool=AliasMap;note=Dados coletados de fontes públicas; etica=/ethics\n';
- const rows = [['platform', 'status', 'url', 'latencyMs', 'reason', 'heuristic']];
- for (const e of events) {
- if (e.type === 'site_result') {
- const st = e.status;
- rows.push([
- e.id,
- st,
- e.url ?? '',
- String(e.latencyMs ?? ''),
- e.reason ?? '',
- e.heuristic ? 'yes' : ''
- ]);
- }
- }
- const csv = header + rows.map(r => r.map(v => '"' + String(v).replace(/"/g, '""') + '"').join(',')).join('\n');
- const blob = new Blob([csv], { type: 'text/csv' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url; a.download = `aliasmap-${username || 'resultado'}.csv`; a.click();
- URL.revokeObjectURL(url);
+ const handleEthicsClick = () => {
+ setStep(3);
};
return (
-
-
-
AliasMap
-
Mapeie possíveis perfis por username em plataformas públicas. Coleta ética e sem persistência.
-
- setUsername(e.target.value)}
- onKeyDown={(e) => { if (e.key === 'Enter' && canStart) startScan(); }}
- disabled={running}
- style={{ padding: 8, border: '1px solid #d1d5db', borderRadius: 6 }}
- />
-
-
-
- Aviso e Uso Ético
-
-
- {/* Progress hidden */}
-
- {running ? (
-
- ⏳
- Varredura em andamento…
-
- ) : null}
-
-
- Mindmap (pré-visualização)
- {/* Filter removed: always showing only found results */}
-
- e.status === 'found')}
- />
-
-
-
+
+
+
+
+
+
+ AliasMap
+
+
+
+ {step > 0 && (
+
+
+
+
+
+ )}
+
+ {step === 0 && (
+
+
+
+
+
+ )}
+
+
+
+ {step === 0 && }
+ {step === 1 && }
+ {step === 2 && (
+
+ )}
+ {step === 3 && }
+
+
+
);
}
diff --git a/apps/web/components/CustomLoading.tsx b/apps/web/components/CustomLoading.tsx
new file mode 100644
index 0000000..f8e8f2f
--- /dev/null
+++ b/apps/web/components/CustomLoading.tsx
@@ -0,0 +1,117 @@
+"use client";
+
+import * as React from "react";
+import Box from "@mui/material/Box";
+import { useTheme, alpha, lighten, darken } from "@mui/material/styles";
+import { keyframes } from "@mui/system";
+import type { CustomLoadingProps } from "../types";
+
+const barAnim = keyframes`
+ 0% { background-position: left; }
+ 100% { background-position: right; }
+`;
+const searchAnim = keyframes`
+ 0% { transform: translateX(0%) rotate(70deg); }
+ 100% { transform: translateX(100px) rotate(10deg); }
+`;
+
+export default function CustomLoadingScreen({ width = 130, barHeight = 8, ariaLabel = "Carregando" }: CustomLoadingProps) {
+ const theme = useTheme();
+ const primary = theme.palette.primary.main;
+ const primaryLight = theme.palette.primary.light ?? lighten(primary, 0.35);
+ const primaryDark = theme.palette.primary.dark ?? darken(primary, 0.3);
+ const primarySoft = alpha(primary, 0.22);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/components/EthicsPage.tsx b/apps/web/components/EthicsPage.tsx
new file mode 100644
index 0000000..74084d7
--- /dev/null
+++ b/apps/web/components/EthicsPage.tsx
@@ -0,0 +1,55 @@
+import { Container, Paper, Typography, Box, Alert, useTheme } from "@mui/material";
+import { AlertTriangle, Info, CheckCircle } from "lucide-react";
+import { disclaimerPt } from "../lib/constants";
+
+export const metadata = { title: "Aviso e Uso Ético — AliasMap" };
+
+export default function EthicsPage() {
+ const theme = useTheme();
+ return (
+
+ } sx={{ borderRadius: 2, background: "transparent" }}>
+
+ Leia atentamente todos os termos antes de utilizar nossos serviços
+
+
+
+
+
+
+ Aviso e Uso Ético
+
+
+
+ {disclaimerPt}
+
+
+
+
+
+
+ Ao utilizar nossos serviços, você concorda com estes termos
+
+
+
+
+ );
+}
diff --git a/apps/web/components/LoadingScreen.tsx b/apps/web/components/LoadingScreen.tsx
new file mode 100644
index 0000000..317e0d8
--- /dev/null
+++ b/apps/web/components/LoadingScreen.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import * as React from "react";
+import { Stack, Typography, LinearProgress, Alert } from "@mui/material";
+import CustomLoadingScreen from "./CustomLoading";
+import type { LoadingProps } from "../types";
+
+export default function LoadingScreen({ progress, error }: LoadingProps) {
+ return (
+
+ Varredura em andamento
+
+
+
+
+
+ {!!error && {error}}
+
+ );
+}
diff --git a/apps/web/components/MindmapPreview.tsx b/apps/web/components/MindmapPreview.tsx
index c5edb49..ae3b7ba 100644
--- a/apps/web/components/MindmapPreview.tsx
+++ b/apps/web/components/MindmapPreview.tsx
@@ -1,88 +1,435 @@
-import React, { useMemo } from 'react';
-
-type Item = {
- platform: string;
- status: 'found' | 'not_found' | 'inconclusive' | string;
- rawStatus?: 'found' | 'not_found' | 'inconclusive' | string;
- heuristic?: boolean;
- url?: string;
-};
-
-export function MindmapPreview({ username, items }: { username: string; items: Item[] }) {
- const width = 600;
- const height = 420;
- const cx = width / 2;
- const cy = height / 2;
- const radius = 140;
-
- const nodes = useMemo(() => {
+"use client";
+
+import * as React from "react";
+import { ExternalLink, Info, Dot, ShieldCheck } from "lucide-react";
+import {
+ Card,
+ CardHeader,
+ CardContent,
+ Divider,
+ Box,
+ Stack,
+ Typography,
+ Chip,
+ Tooltip,
+ useTheme,
+ Button,
+} from "@mui/material";
+import DownloadIcon from "@mui/icons-material/Download";
+import type { MindmapPreviewProps, EdgeProps, LegendProps, SiteEvent } from "../types";
+
+/* ===== Constantes/Utils ===== */
+const STATUS_COLORS = {
+ found: "#10B981",
+ inconclusive: "#F59E0B",
+ not_found: "#9CA3AF",
+ unknown: "#EF4444",
+} as const;
+
+const NODE_R = 20;
+const CORE_R = 34;
+
+function colorFor(status: string): string {
+ if (status === "found") return STATUS_COLORS.found;
+ if (status === "inconclusive") return STATUS_COLORS.inconclusive;
+ if (status === "not_found") return STATUS_COLORS.not_found;
+ return STATUS_COLORS.unknown;
+}
+
+function isSafeHttpUrl(u?: string): u is string {
+ if (!u) return false;
+ try {
+ const url = new URL(u);
+ return url.protocol === "http:" || url.protocol === "https:";
+ } catch {
+ return false;
+ }
+}
+
+function truncate(s: string, max = 12) {
+ return s.length > max ? s.slice(0, max - 1) + "…" : s;
+}
+
+function useMeasure() {
+ const ref = React.useRef(null);
+ const [rect, setRect] = React.useState<{ width: number; height: number }>({ width: 640, height: 420 });
+
+ React.useLayoutEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ const ro = new ResizeObserver((entries) => {
+ for (const e of entries) {
+ const cr = e.contentRect;
+ setRect({ width: Math.max(320, cr.width), height: Math.max(280, cr.height) });
+ }
+ });
+ ro.observe(el);
+ const r = el.getBoundingClientRect();
+ setRect({ width: Math.max(320, r.width), height: Math.max(280, r.height) });
+ return () => ro.disconnect();
+ }, []);
+
+ return { ref, rect };
+}
+
+export function MindmapPreview({
+ username,
+ items,
+ width,
+ height,
+ radius = 150,
+ className,
+ maxLabel = 12,
+ events,
+ exportData = true,
+}: MindmapPreviewProps) {
+ const theme = useTheme();
+ const prefersReduced =
+ typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
+
+ const { ref, rect } = useMeasure();
+ const idPrefix = React.useId();
+ const W = width ?? rect.width;
+ const H = height ?? rect.height;
+ const cx = W / 2;
+ const cy = H / 2;
+ const effectiveRadius = Math.max(80, Math.min(radius, Math.min(W, H) * 0.42 - 10));
+ const nodes = React.useMemo(() => {
const N = Math.max(1, items.length);
return items.map((it, i) => {
const angle = (i / N) * Math.PI * 2 - Math.PI / 2;
- const x = cx + radius * Math.cos(angle);
- const y = cy + radius * Math.sin(angle);
- return { ...it, x, y };
+ const x = cx + effectiveRadius * Math.cos(angle);
+ const y = cy + effectiveRadius * Math.sin(angle);
+ return { ...it, x, y, angle, safeUrl: isSafeHttpUrl(it.url) ? it.url : undefined };
});
- }, [items, cx, cy, radius]);
+ }, [items, cx, cy, effectiveRadius]);
- const colorFor = (status: string) => {
- if (status === 'found') return '#10B981'; // green
- if (status === 'inconclusive') return '#F59E0B'; // amber
- if (status === 'not_found') return '#9CA3AF'; // gray
- return '#EF4444'; // red for errors/unknown
- };
+ const gradCoreId = `${idPrefix}-mm-core`;
+ const shadowId = `${idPrefix}-mm-shadow`;
+ const ariaLabel = `Mapa de evidências para ${username || "alvo"} com ${items.length} plataformas`;
+
+ function exportJson(username: string, events: SiteEvent[]) {
+ const payload = { username, events };
+ const blob = new Blob([JSON.stringify(payload, null, 2)], {
+ type: "application/json",
+ });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `aliasmap-${username || "resultado"}.json`;
+ a.click();
+ URL.revokeObjectURL(url);
+ }
+
+ function exportCsv(username: string, events: SiteEvent[]) {
+ const header = `user=${username}\n`;
+ const rows = [["platform", "status", "url"]];
+ for (const e of events) {
+ if (e.type === "site_result") {
+ rows.push([e.id, e.status, e.url ?? ""]);
+ }
+ }
+ const csv = header + rows.map((r) => r.map((v) => `"${v}"`).join(",")).join("\n");
+ const blob = new Blob([csv], { type: "text/csv" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `aliasmap-${username || "resultado"}.csv`;
+ a.click();
+ URL.revokeObjectURL(url);
+ }
+
+ if (!items || items.length === 0) {
+ return (
+
+
+
+
+
+
+ Mapa de Evidências
+
+
+ }
+ subheader={
+
+ Sem dados para exibir
+
+ }
+ />
+
+ );
+ }
return (
-
-
+
+ {nodes.map((n, idx) => (
+
+ ))}
+
+ {/* nós */}
+ {nodes.map((n, idx) => {
+ const stroke = colorFor(n.rawStatus ?? n.status);
+ const isHeuristic = Boolean(n.heuristic);
+ const label = truncate(n.platform, maxLabel);
+
+ const group = (
+ {
+ if (!n.safeUrl) return;
+ if (e.key === "Enter" || e.key === " ") {
+ window.open(n.safeUrl, "_blank", "noopener,noreferrer");
+ e.preventDefault();
+ }
+ }}
+ >
+
+ {`${n.platform} — ${n.rawStatus ?? n.status}${isHeuristic ? " (heurístico)" : ""}${
+ n.safeUrl ? ` — ${n.safeUrl}` : ""
+ }`}
+
+
+
+ {label}
+
+ {isHeuristic && (
+
+ heurístico
+
+ )}
+
+ );
+
+ if (!n.safeUrl) return group;
+
+ return (
+
+
+
+
+ {n.platform}
+
+
+ {isHeuristic && (
+
+ )}
+
+
+ {n.safeUrl}
+
+
+
+ Abrir
+
+
+ }
+ >
+
+ {group}
+
+
+ );
+ })}
+
+
+ {exportData && (
+
+ }
+ disabled={!events.length}
+ onClick={() => exportCsv(username, events)}
+ >
+ CSV
+
+ }
+ disabled={!events.length}
+ onClick={() => exportJson(username, events)}
+ >
+ JSON
+
+
+ )}
+
+
+ );
+}
+
+function Edge({ idx, x1, y1, x2, y2, animate, prefix }: EdgeProps) {
+ const gradId = `${prefix}-edge-grad-${idx}`;
+ return (
+
+
+
+
+
+
+ {animate ? (
+
+ ) : null}
+
+
+ );
+}
+
+function Legend({ label, color }: LegendProps) {
+ return (
+
+
+ {label}
+
+ }
+ sx={{ "& .MuiChip-label": { display: "flex", alignItems: "center", py: 0.5, px: 1 } }}
+ />
);
}
diff --git a/apps/web/components/ResultsScreen.tsx b/apps/web/components/ResultsScreen.tsx
new file mode 100644
index 0000000..2ef6dfe
--- /dev/null
+++ b/apps/web/components/ResultsScreen.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import * as React from "react";
+import { Stack, Typography, Alert } from "@mui/material";
+import { MindmapPreview } from "@/components/MindmapPreview";
+import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
+import type { ResultsScreenProps } from "../types";
+
+export default function ResultsScreen({ hasData, trimmed, foundOnly, events }: ResultsScreenProps) {
+ return (
+
+ Resultado
+ {!hasData ? (
+ }>
+ Nenhuma evidência confirmada.
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/apps/web/components/SearchScreen.tsx b/apps/web/components/SearchScreen.tsx
new file mode 100644
index 0000000..2583a19
--- /dev/null
+++ b/apps/web/components/SearchScreen.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import * as React from "react";
+import { Stack, TextField, Button } from "@mui/material";
+import PlayArrowIcon from "@mui/icons-material/PlayArrow";
+import type { SearchScreenProps } from "../types";
+
+export default function SearchScreen({ onStart, loading = false }: SearchScreenProps) {
+ const [username, setUsername] = React.useState("");
+ const trimmed = username.trim();
+ const isValid = trimmed.length >= 3 && /^[a-zA-Z0-9._-]{3,32}$/.test(trimmed);
+
+ return (
+
+ setUsername(e.target.value)}
+ disabled={loading}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && isValid) onStart(trimmed);
+ }}
+ inputProps={{ "aria-label": "username" }}
+ />
+
+
+ }
+ onClick={() => onStart(trimmed)}
+ disabled={!isValid || loading}
+ aria-disabled={!isValid || loading}
+ >
+ Iniciar
+
+
+
+ );
+}
diff --git a/apps/web/components/theme-provider.tsx b/apps/web/components/theme-provider.tsx
new file mode 100644
index 0000000..94f7ca0
--- /dev/null
+++ b/apps/web/components/theme-provider.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import * as React from "react";
+import { createTheme, ThemeProvider, CssBaseline } from "@mui/material";
+import type { NodeProps } from "../types";
+
+const LIGHT = {
+ primary: { main: "#0B1B34", light: "#4A90E2", dark: "#003B5C" },
+ secondary: { main: "#16C5C0" },
+ background: { default: "#f0f0f0", paper: "#FFFFFF" },
+ text: { primary: "#0B1B34", secondary: "#374151" },
+ error: { main: "#EF4444" },
+ found: "#10B981",
+ inconclusive: "#F59E0B",
+ not_found: "#6B7280",
+ unknown: "#EF4444",
+} as const;
+
+const DARK = {
+ primary: { main: "#0D2D3A", light: "#4A90E2", dark: "#003B5C" },
+ secondary: { main: "#F9FAFB" },
+ background: { default: "#0B1B34", paper: "#111827" },
+ text: { primary: "#FFFFFF", secondary: "#9CA3AF" },
+ error: { main: "#EF4444" },
+ found: "#10B981",
+ inconclusive: "#F59E0B",
+ not_found: "#9CA3AF",
+ unknown: "#EF4444",
+} as const;
+
+export function AppThemeProvider({ children }: NodeProps) {
+ const [mode, setMode] = React.useState<"light" | "dark">("dark");
+ React.useEffect(() => {
+ if (typeof window === "undefined" || !window.matchMedia) return;
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
+ const apply = (e: MediaQueryListEvent | MediaQueryList) => {
+ const isDark = "matches" in e ? e.matches : mq.matches;
+ setMode(isDark ? "dark" : "light");
+ };
+ apply(mq);
+ mq.addEventListener("change", apply);
+ return () => {
+ mq.removeEventListener("change", apply);
+ };
+ }, []);
+
+ const theme = React.useMemo(
+ () =>
+ createTheme({
+ palette: {
+ mode,
+ ...(mode === "light" ? (LIGHT as any) : (DARK as any)),
+ },
+ typography: {
+ fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial",
+ },
+ }),
+ [mode]
+ );
+
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/apps/web/lib/constants.ts b/apps/web/lib/constants.ts
index b3d86dd..5963348 100644
--- a/apps/web/lib/constants.ts
+++ b/apps/web/lib/constants.ts
@@ -1,6 +1,4 @@
-export const disclaimerPt = `# Aviso e Uso Ético
-
-Este projeto (AliasMap) é não-oficial e não afiliado ao Sherlock Project. Destina-se a fins educacionais e investigações legítimas/autorizadas. Ao continuar, você declara que entende e concorda com os termos abaixo:
+export const disclaimerPt = `Este projeto (AliasMap) é não-oficial e não afiliado ao Sherlock Project. Destina-se a fins educacionais e investigações legítimas/autorizadas. Ao continuar, você declara que entende e concorda com os termos abaixo:
- Coletamos somente informações publicamente acessíveis. Não realizamos login, não contornamos controles e não interagimos com fluxos de recuperação além da página inicial (apenas 1 tentativa e parsing estático).
- Não persistimos dados no servidor. Resultados são exibidos apenas na sua sessão; export é local e sob sua responsabilidade.
diff --git a/apps/web/lib/utils.ts b/apps/web/lib/utils.ts
new file mode 100644
index 0000000..0f7074a
--- /dev/null
+++ b/apps/web/lib/utils.ts
@@ -0,0 +1,4 @@
+// Se usar shadcn: ajuste para o seu caminho util de "cn"
+export function cn(...classes: (string | undefined | false)[]) {
+ return classes.filter(Boolean).join(" ");
+}
\ No newline at end of file
diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts
index 709e66b..4f11a03 100644
--- a/apps/web/next-env.d.ts
+++ b/apps/web/next-env.d.ts
@@ -3,4 +3,3 @@
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
-
diff --git a/apps/web/package.json b/apps/web/package.json
index cb82fda..703441c 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -14,6 +14,14 @@
"e2e": "echo 'e2e offline placeholder' && exit 0"
},
"dependencies": {
+ "@emotion/react": "^11.14.0",
+ "@emotion/styled": "^11.14.1",
+ "@mui/icons-material": "^5.18.0",
+ "@mui/material": "^5.18.0",
+ "@mui/system": "^5.18.0",
+ "@mui/utils": "^5.17.1",
+ "@tailwindcss/postcss": "^4.1.13",
+ "lucide-react": "^0.542.0",
"next": "14.2.5",
"react": "18.3.1",
"react-dom": "18.3.1"
@@ -22,10 +30,13 @@
"@types/node": "20.12.12",
"@types/react": "18.2.66",
"@types/react-dom": "18.2.22",
+ "@vitest/coverage-v8": "1.6.0",
+ "autoprefixer": "^10.4.19",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
"eslint": "8.57.0",
"eslint-config-next": "14.2.5",
"typescript": "5.5.3",
- "vitest": "1.6.0",
- "@vitest/coverage-v8": "1.6.0"
+ "vitest": "1.6.0"
}
}
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index 82edaa8..12beaf7 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2020",
- "lib": ["dom", "dom.iterable", "es2020"],
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "es2020"
+ ],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
@@ -13,9 +17,25 @@
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
- "types": ["node"]
+ "types": [
+ "node"
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ },
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ]
},
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
- "exclude": ["node_modules"]
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
}
-
diff --git a/apps/web/types/index.ts b/apps/web/types/index.ts
new file mode 100644
index 0000000..c90c2cf
--- /dev/null
+++ b/apps/web/types/index.ts
@@ -0,0 +1,79 @@
+import type { ReactNode } from "react";
+
+export type NodeProps = Readonly<{ children: ReactNode }>;
+
+export type RootLayoutProps = Readonly<{
+ children: ReactNode;
+}>;
+
+export type CustomLoadingProps = Readonly<{
+ width?: number;
+ barHeight?: number;
+ ariaLabel?: string;
+}>;
+
+export type LoadingProps = Readonly<{
+ progress: { done: number; total: number };
+ error?: string;
+}>;
+
+export type LegendProps = Readonly<{ label: string; color: string }>;
+
+export type EdgeProps = Readonly<{
+ idx: number;
+ x1: number;
+ y1: number;
+ x2: number;
+ y2: number;
+ animate?: boolean;
+ prefix: string;
+}>;
+
+export type Status = "found" | "not_found" | "inconclusive";
+
+export type MindmapItem = Readonly<{
+ platform: string;
+ status: Status;
+ rawStatus?: Status;
+ heuristic?: boolean;
+ url?: string;
+}>;
+
+export type MindmapPreviewProps = Readonly<{
+ username: string;
+ items: MindmapItem[];
+ width?: number;
+ height?: number;
+ radius?: number;
+ className?: string;
+ maxLabel?: number;
+ events: SiteEvent[];
+ exportData?: boolean;
+}>;
+
+export type SiteEvent =
+ | { type: "site_start"; id: string }
+ | {
+ type: "site_result";
+ id: string;
+ status: string;
+ url?: string;
+ latencyMs?: number;
+ reason?: string;
+ heuristic?: boolean;
+ }
+ | { type: "site_error"; id: string; reason: string }
+ | { type: "progress"; done: number; total: number }
+ | { type: "done"; summary: { done: number; total: number } };
+
+export type ResultsScreenProps = Readonly<{
+ events: SiteEvent[];
+ hasData: boolean;
+ trimmed: string;
+ foundOnly: MindmapItem[];
+}>;
+
+export type SearchScreenProps = Readonly<{
+ onStart: (username: string) => void;
+ loading?: boolean;
+}>;