muitas melhorias pra descrever

This commit is contained in:
Rodrigo Bertoldo de Sousa Martins committed 2026-09-11 12:54:25 -03:00
1 parent 89b08a19ef
commit c8fba4d240
21 files changed
+971 -17

No files matched your search

+249
View File
@@ -0,0 +1,249 @@
import { useEffect, useState, type FormEvent } from "react";
import { api, type AdminStats } from "../lib/api.js";
const TOKEN_KEY = "anatomia_admin_token";
function AdminLogin({ onLogin }: { onLogin: (token: string) => void }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function submit(e: FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const { token } = await api.adminLogin(email, password);
localStorage.setItem(TOKEN_KEY, token);
onLogin(token);
} catch {
setError("E-mail ou senha inválidos.");
} finally {
setLoading(false);
}
}
return (
<div className="flex h-full items-center justify-center bg-ink p-4">
<form
onSubmit={submit}
className="w-full max-w-sm rounded-lg border border-white/10 bg-panel p-6"
>
<h1 className="mb-4 text-lg font-semibold text-white">Painel de Estatísticas</h1>
<label className="mb-3 block">
<span className="mb-1 block text-xs text-slate-400">E-mail</span>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full rounded border border-white/10 bg-black/20 p-2 text-sm text-white focus:outline-none focus:ring-1 focus:ring-accent"
/>
</label>
<label className="mb-4 block">
<span className="mb-1 block text-xs text-slate-400">Senha</span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full rounded border border-white/10 bg-black/20 p-2 text-sm text-white focus:outline-none focus:ring-1 focus:ring-accent"
/>
</label>
{error && <p className="mb-3 text-xs text-red-400">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full rounded bg-accent py-2 text-sm font-medium text-white disabled:opacity-50"
>
{loading ? "Entrando..." : "Entrar"}
</button>
</form>
</div>
);
}
function StatCard({ label, value }: { label: string; value: string | number }) {
return (
<div className="rounded-lg border border-white/10 bg-panel p-4">
<p className="text-2xl font-semibold text-white">{value}</p>
<p className="text-xs text-slate-400">{label}</p>
</div>
);
}
function StatsDashboard({ token, onLogout }: { token: string; onLogout: () => void }) {
const [stats, setStats] = useState<AdminStats | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const data = await api.adminStats(token);
if (!cancelled) setStats(data);
} catch {
if (!cancelled) setError("Sessão expirada, faça login novamente.");
}
}
load();
const id = setInterval(load, 30_000);
return () => {
cancelled = true;
clearInterval(id);
};
}, [token]);
if (error) {
return (
<div className="p-6 text-center text-sm text-red-400">
{error}
<div className="mt-3">
<button onClick={onLogout} className="rounded bg-accent px-3 py-1.5 text-xs text-white">
Voltar ao login
</button>
</div>
</div>
);
}
if (!stats) return <div className="p-6 text-sm text-slate-400">Carregando...</div>;
return (
<div className="h-full overflow-y-auto p-4 sm:p-6">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-lg font-semibold text-white">Painel de Estatísticas</h1>
<button
onClick={onLogout}
className="rounded px-3 py-1.5 text-xs text-slate-300 hover:bg-white/5"
>
Sair
</button>
</div>
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Online agora" value={stats.onlineNow} />
<StatCard label="Visitas (24h)" value={stats.visitsLast24h} />
<StatCard label="Visitas (7 dias)" value={stats.visitsLast7d} />
<StatCard label="Total de visitas" value={stats.totalVisits} />
</div>
<div className="mb-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="rounded-lg border border-white/10 bg-panel p-4">
<h2 className="mb-3 text-sm font-semibold text-white">Sistemas mais acessados</h2>
<table className="w-full text-xs text-slate-300">
<tbody>
{stats.visitsBySystem.map((row) => (
<tr key={row.system} className="border-t border-white/5">
<td className="py-1.5">{row.system ?? "—"}</td>
<td className="py-1.5 text-right font-medium text-white">{row.count}</td>
</tr>
))}
{stats.visitsBySystem.length === 0 && (
<tr>
<td className="py-2 text-slate-500">Sem dados ainda.</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="rounded-lg border border-white/10 bg-panel p-4">
<h2 className="mb-3 text-sm font-semibold text-white">Países de acesso</h2>
<table className="w-full text-xs text-slate-300">
<tbody>
{stats.visitsByCountry.map((row) => (
<tr key={row.country} className="border-t border-white/5">
<td className="py-1.5">{row.country}</td>
<td className="py-1.5 text-right font-medium text-white">{row.count}</td>
</tr>
))}
{stats.visitsByCountry.length === 0 && (
<tr>
<td className="py-2 text-slate-500">Sem dados ainda.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<div className="mb-6 rounded-lg border border-white/10 bg-panel p-4">
<h2 className="mb-3 text-sm font-semibold text-white">Visitas recentes</h2>
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] text-xs text-slate-300">
<thead>
<tr className="text-left text-slate-500">
<th className="pb-2 pr-3">Data/hora</th>
<th className="pb-2 pr-3">IP</th>
<th className="pb-2 pr-3">Porta</th>
<th className="pb-2 pr-3">País/Cidade</th>
<th className="pb-2 pr-3">Página</th>
<th className="pb-2">Sistema</th>
</tr>
</thead>
<tbody>
{stats.recentVisits.map((v) => (
<tr key={v.id} className="border-t border-white/5">
<td className="py-1.5 pr-3 whitespace-nowrap">
{new Date(v.createdAt).toLocaleString("pt-BR")}
</td>
<td className="py-1.5 pr-3 font-mono">{v.ip}</td>
<td className="py-1.5 pr-3 font-mono">{v.port ?? "—"}</td>
<td className="py-1.5 pr-3">
{v.country ?? "—"}
{v.city ? ` / ${v.city}` : ""}
</td>
<td className="py-1.5 pr-3">{v.path}</td>
<td className="py-1.5">{v.system ?? "—"}</td>
</tr>
))}
{stats.recentVisits.length === 0 && (
<tr>
<td className="py-2 text-slate-500" colSpan={6}>
Sem visitas registradas ainda.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<div className="rounded-lg border border-white/10 bg-panel p-4">
<h2 className="mb-3 text-sm font-semibold text-white">Feedback dos usuários</h2>
<div className="space-y-3">
{stats.feedback.map((f) => (
<div key={f.id} className="rounded border border-white/5 bg-black/10 p-3">
<div className="mb-1 flex items-center justify-between text-[11px] text-slate-500">
<span>{new Date(f.createdAt).toLocaleString("pt-BR")}</span>
<span className="flex items-center gap-2">
{f.rating && <span className="text-yellow-400">{"★".repeat(f.rating)}</span>}
{f.page && <span>{f.page}</span>}
</span>
</div>
<p className="text-sm text-slate-200">{f.message}</p>
{f.email && <p className="mt-1 text-[11px] text-slate-500">{f.email}</p>}
</div>
))}
{stats.feedback.length === 0 && (
<p className="text-xs text-slate-500">Nenhum feedback recebido ainda.</p>
)}
</div>
</div>
</div>
);
}
export default function AdminStatsPage() {
const [token, setToken] = useState<string | null>(() => localStorage.getItem(TOKEN_KEY));
function logout() {
localStorage.removeItem(TOKEN_KEY);
setToken(null);
}
if (!token) return <AdminLogin onLogin={setToken} />;
return <StatsDashboard token={token} onLogout={logout} />;
}
+8
View File
@@ -8,6 +8,7 @@ import { SearchNav } from "../components/SearchNav.js";
import { StructureInfoPanel } from "../components/StructureInfoPanel.js";
import { QuickRegionNav } from "../components/QuickRegionNav.js";
import { SkinDiagram } from "../components/SkinDiagram.js";
import { trackVisit } from "../lib/analytics.js";
// Em telas pequenas não há espaço para os 3 painéis lado a lado — os
// laterais viram gavetas (drawers) que deslizam por cima do visualizador
@@ -27,6 +28,13 @@ export default function AtlasPage() {
}
}, [selectedStructureId]);
// Registra qual(is) sistema(s) do atlas o visitante ligou — é o que
// alimenta o ranking de "sistemas mais acessados" no painel de
// estatísticas. Só manda algo se o visitante aceitou o aviso de cookies.
useEffect(() => {
for (const slug of visibleSystems) trackVisit("/", slug);
}, [visibleSystems]);
return (
<div className="h-full relative md:grid md:grid-cols-[280px_1fr_320px]">
<aside