muitas melhorias pra descrever
This commit is contained in:
1 parent
89b08a19ef
commit
c8fba4d240
21 files changed
+971
-17
No files matched your search
@@ -4,6 +4,7 @@ import AtlasPage from "./routes/AtlasPage.js";
|
||||
import PlanesPage from "./routes/PlanesPage.js";
|
||||
import QuizPage from "./routes/QuizPage.js";
|
||||
import PhysicalExamPage from "./routes/PhysicalExamPage.js";
|
||||
import AdminStatsPage from "./routes/AdminStatsPage.js";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -14,6 +15,8 @@ export default function App() {
|
||||
<Route path="/exame-fisico" element={<PhysicalExamPage />} />
|
||||
<Route path="/quiz" element={<QuizPage />} />
|
||||
</Route>
|
||||
{/* Fora do Layout: painel interno, sem o menu/nav do atlas. */}
|
||||
<Route path="/estatisticas" element={<AdminStatsPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getConsent, setConsent, startHeartbeat, type ConsentValue } from "../lib/analytics.js";
|
||||
|
||||
export function CookieConsent() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const current = getConsent();
|
||||
if (current === null) {
|
||||
setVisible(true);
|
||||
} else if (current === "accepted") {
|
||||
startHeartbeat();
|
||||
}
|
||||
}, []);
|
||||
|
||||
function choose(value: ConsentValue) {
|
||||
setConsent(value);
|
||||
setVisible(false);
|
||||
if (value === "accepted") startHeartbeat();
|
||||
}
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 z-50 border-t border-white/10 bg-panel/95 backdrop-blur px-4 py-3 sm:px-6">
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-xs text-slate-300 sm:text-sm">
|
||||
Este site coleta dados básicos de uso (endereço IP, país aproximado e páginas
|
||||
acessadas) apenas para estatísticas anônimas de acesso. Nenhum dado é vendido ou
|
||||
compartilhado com terceiros. Você pode recusar sem perder acesso ao atlas.
|
||||
</p>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
onClick={() => choose("declined")}
|
||||
className="rounded px-3 py-1.5 text-xs text-slate-300 hover:bg-white/5 sm:text-sm"
|
||||
>
|
||||
Recusar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => choose("accepted")}
|
||||
className="rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:opacity-90 sm:text-sm"
|
||||
>
|
||||
Aceitar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { api } from "../lib/api.js";
|
||||
|
||||
export function FeedbackWidget() {
|
||||
const location = useLocation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [rating, setRating] = useState<number | null>(null);
|
||||
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");
|
||||
|
||||
function reset() {
|
||||
setMessage("");
|
||||
setEmail("");
|
||||
setRating(null);
|
||||
setStatus("idle");
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!message.trim()) return;
|
||||
setStatus("sending");
|
||||
try {
|
||||
await api.sendFeedback({
|
||||
message: message.trim(),
|
||||
email: email.trim() || undefined,
|
||||
rating: rating ?? undefined,
|
||||
page: location.pathname,
|
||||
});
|
||||
setStatus("sent");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed bottom-4 right-4 z-40 rounded-full bg-accent px-4 py-2.5 text-sm font-medium text-white shadow-lg hover:opacity-90"
|
||||
>
|
||||
💬 Feedback
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-end bg-black/40 p-4 sm:items-center sm:justify-center">
|
||||
<div className="w-full max-w-sm rounded-lg border border-white/10 bg-panel p-4 shadow-xl">
|
||||
{status === "sent" ? (
|
||||
<div className="py-4 text-center">
|
||||
<p className="text-sm text-slate-200">Obrigado pelo feedback! 🙏</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
reset();
|
||||
setOpen(false);
|
||||
}}
|
||||
className="mt-3 rounded bg-accent px-3 py-1.5 text-xs font-medium text-white"
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-white">Enviar feedback</h2>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-slate-400 hover:text-white"
|
||||
aria-label="Fechar"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setRating(n)}
|
||||
className={`text-lg ${rating && n <= rating ? "text-yellow-400" : "text-slate-600"}`}
|
||||
aria-label={`Nota ${n}`}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="O que você achou? Encontrou algum erro ou tem alguma sugestão?"
|
||||
rows={4}
|
||||
className="w-full resize-none rounded border border-white/10 bg-black/20 p-2 text-sm text-white placeholder:text-slate-500 focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Seu e-mail (opcional)"
|
||||
className="mt-2 w-full rounded border border-white/10 bg-black/20 p-2 text-sm text-white placeholder:text-slate-500 focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
|
||||
{status === "error" && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
Não foi possível enviar. Tente novamente.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={!message.trim() || status === "sending"}
|
||||
className="mt-3 w-full rounded bg-accent py-2 text-sm font-medium text-white disabled:opacity-50"
|
||||
>
|
||||
{status === "sending" ? "Enviando..." : "Enviar"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,21 @@
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||
import { OnlineCounter } from "./OnlineCounter.js";
|
||||
import { CookieConsent } from "./CookieConsent.js";
|
||||
import { FeedbackWidget } from "./FeedbackWidget.js";
|
||||
import { trackVisit } from "../lib/analytics.js";
|
||||
|
||||
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||
`px-3 py-1.5 rounded text-sm ${isActive ? "bg-accent text-white" : "text-slate-300 hover:bg-white/5"}`;
|
||||
|
||||
export function Layout() {
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
trackVisit(location.pathname);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<div className="h-[100dvh] flex flex-col">
|
||||
<header className="flex items-center gap-2 sm:gap-4 px-2 sm:px-4 py-2 border-b border-white/10 bg-panel overflow-x-auto">
|
||||
@@ -24,6 +36,7 @@ export function Layout() {
|
||||
Quiz
|
||||
</NavLink>
|
||||
</nav>
|
||||
<OnlineCounter />
|
||||
<a
|
||||
href="https://github.com/LluisV/Z-Anatomy-Sample"
|
||||
target="_blank"
|
||||
@@ -36,6 +49,8 @@ export function Layout() {
|
||||
<main className="flex-1 min-h-0">
|
||||
<Outlet />
|
||||
</main>
|
||||
<FeedbackWidget />
|
||||
<CookieConsent />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../lib/api.js";
|
||||
|
||||
export function OnlineCounter() {
|
||||
const [count, setCount] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function poll() {
|
||||
try {
|
||||
const { count } = await api.onlineCount();
|
||||
if (!cancelled) setCount(count);
|
||||
} catch {
|
||||
// Silencioso — não é crítico pro uso do atlas.
|
||||
}
|
||||
}
|
||||
poll();
|
||||
const id = setInterval(poll, 20_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (count === null) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
className="hidden items-center gap-1.5 text-[11px] text-slate-400 sm:inline-flex"
|
||||
title="Pessoas com a página aberta agora"
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||
{count} online
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { api } from "./api.js";
|
||||
|
||||
const CONSENT_KEY = "anatomia_consent";
|
||||
const SESSION_KEY = "anatomia_session_id";
|
||||
|
||||
export type ConsentValue = "accepted" | "declined";
|
||||
|
||||
export function getConsent(): ConsentValue | null {
|
||||
try {
|
||||
const v = localStorage.getItem(CONSENT_KEY);
|
||||
return v === "accepted" || v === "declined" ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setConsent(value: ConsentValue): void {
|
||||
try {
|
||||
localStorage.setItem(CONSENT_KEY, value);
|
||||
} catch {
|
||||
// Sem storage disponível (aba privada etc.) — segue sem rastrear.
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionId(): string {
|
||||
try {
|
||||
let id = localStorage.getItem(SESSION_KEY);
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
localStorage.setItem(SESSION_KEY, id);
|
||||
}
|
||||
return id;
|
||||
} catch {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
}
|
||||
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
export function startHeartbeat(): void {
|
||||
if (heartbeatTimer) return;
|
||||
const beat = () => {
|
||||
if (getConsent() !== "accepted") return;
|
||||
void api.heartbeat(getSessionId());
|
||||
};
|
||||
beat();
|
||||
heartbeatTimer = setInterval(beat, 20_000);
|
||||
}
|
||||
|
||||
export function stopHeartbeat(): void {
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function trackVisit(path: string, system?: string): void {
|
||||
if (getConsent() !== "accepted") return;
|
||||
void api.trackVisit(path, system);
|
||||
}
|
||||
@@ -32,6 +32,39 @@ export interface QuizCard {
|
||||
dueAt: string;
|
||||
}
|
||||
|
||||
export interface PageVisit {
|
||||
id: string;
|
||||
path: string;
|
||||
system: string | null;
|
||||
ip: string;
|
||||
port: number | null;
|
||||
country: string | null;
|
||||
city: string | null;
|
||||
userAgent: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface FeedbackEntry {
|
||||
id: string;
|
||||
message: string;
|
||||
email: string | null;
|
||||
rating: number | null;
|
||||
page: string | null;
|
||||
ip: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
onlineNow: number;
|
||||
totalVisits: number;
|
||||
visitsLast24h: number;
|
||||
visitsLast7d: number;
|
||||
visitsBySystem: { system: string | null; count: number }[];
|
||||
visitsByCountry: { country: string; count: number }[];
|
||||
recentVisits: PageVisit[];
|
||||
feedback: FeedbackEntry[];
|
||||
}
|
||||
|
||||
const BASE = "/api";
|
||||
|
||||
async function getJson<T>(url: string): Promise<T> {
|
||||
@@ -40,6 +73,22 @@ async function getJson<T>(url: string): Promise<T> {
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function postJson<T>(url: string, body: unknown, token?: string): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
throw new Error(data?.error ?? `POST ${url} failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
systems: () => getJson<AnatomicalSystem[]>(`${BASE}/systems`),
|
||||
|
||||
@@ -72,6 +121,28 @@ export const api = {
|
||||
if (!res.ok) throw new Error(`quizReview failed: ${res.status}`);
|
||||
return res.json();
|
||||
},
|
||||
|
||||
onlineCount: () => getJson<{ count: number }>(`${BASE}/analytics/online-count`),
|
||||
|
||||
trackVisit: (path: string, system?: string) =>
|
||||
postJson<{ ok: true }>(`${BASE}/analytics/visit`, { path, system }).catch(() => {}),
|
||||
|
||||
heartbeat: (sessionId: string) =>
|
||||
postJson<{ ok: true }>(`${BASE}/analytics/heartbeat`, { sessionId }).catch(() => {}),
|
||||
|
||||
sendFeedback: (data: { message: string; email?: string; rating?: number; page?: string }) =>
|
||||
postJson<{ ok: true }>(`${BASE}/feedback`, data),
|
||||
|
||||
adminLogin: (email: string, password: string) =>
|
||||
postJson<{ token: string }>(`${BASE}/admin/login`, { email, password }),
|
||||
|
||||
adminStats: async (token: string): Promise<AdminStats> => {
|
||||
const res = await fetch(`${BASE}/admin/stats`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`GET admin/stats failed: ${res.status}`);
|
||||
return res.json();
|
||||
},
|
||||
};
|
||||
|
||||
export function modelUrl(modelFile: string): string {
|
||||
|
||||
@@ -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