muitas melhorias pra descrever
This commit is contained in:
1 parent
89b08a19ef
commit
c8fba4d240
21 files changed
+971
-17
No files matched your search
@@ -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,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
|
||||
|
||||
Reference in new issue
Block a user