muitas melhorias pra descrever
This commit is contained in:
1 parent
89b08a19ef
commit
c8fba4d240
21 files changed
+971
-17
No files matched your search
@@ -17,10 +17,12 @@
|
|||||||
"@fastify/static": "^7.0.4",
|
"@fastify/static": "^7.0.4",
|
||||||
"@prisma/client": "^5.20.0",
|
"@prisma/client": "^5.20.0",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
|
"geoip-lite": "^1.4.10",
|
||||||
"prisma": "^5.20.0",
|
"prisma": "^5.20.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/geoip-lite": "^1.4.4",
|
||||||
"@types/node": "^20.14.15",
|
"@types/node": "^20.14.15",
|
||||||
"tsx": "^4.19.1",
|
"tsx": "^4.19.1",
|
||||||
"typescript": "^5.6.2"
|
"typescript": "^5.6.2"
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "page_visits" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"path" TEXT NOT NULL,
|
||||||
|
"system" TEXT,
|
||||||
|
"ip" TEXT NOT NULL,
|
||||||
|
"port" INTEGER,
|
||||||
|
"country" TEXT,
|
||||||
|
"city" TEXT,
|
||||||
|
"userAgent" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "page_visits_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "feedback" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"message" TEXT NOT NULL,
|
||||||
|
"email" TEXT,
|
||||||
|
"rating" INTEGER,
|
||||||
|
"page" TEXT,
|
||||||
|
"ip" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "feedback_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "page_visits_createdAt_idx" ON "page_visits"("createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "page_visits_system_idx" ON "page_visits"("system");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "feedback_createdAt_idx" ON "feedback"("createdAt");
|
||||||
@@ -40,6 +40,35 @@ model Structure {
|
|||||||
@@map("structures")
|
@@map("structures")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model PageVisit {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
path String
|
||||||
|
system String?
|
||||||
|
ip String
|
||||||
|
port Int?
|
||||||
|
country String?
|
||||||
|
city String?
|
||||||
|
userAgent String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([createdAt])
|
||||||
|
@@index([system])
|
||||||
|
@@map("page_visits")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Feedback {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
message String
|
||||||
|
email String?
|
||||||
|
rating Int?
|
||||||
|
page String?
|
||||||
|
ip String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([createdAt])
|
||||||
|
@@map("feedback")
|
||||||
|
}
|
||||||
|
|
||||||
model QuizCard {
|
model QuizCard {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
structureId String @unique
|
structureId String @unique
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
|
||||||
|
// Painel de admin de uso pessoal (um único usuário) — token assinado com
|
||||||
|
// HMAC via módulo nativo `crypto`, sem dependência extra de JWT. Mesmo
|
||||||
|
// padrão de "segredo em variável de ambiente com default" já usado pelas
|
||||||
|
// credenciais do Postgres neste projeto (ver docker-compose.yml).
|
||||||
|
const SECRET = process.env.ADMIN_JWT_SECRET ?? "troque-este-segredo-em-producao";
|
||||||
|
const TOKEN_TTL_MS = 12 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export function signAdminToken(email: string): string {
|
||||||
|
const payload = JSON.stringify({ email, exp: Date.now() + TOKEN_TTL_MS });
|
||||||
|
const body = Buffer.from(payload).toString("base64url");
|
||||||
|
const sig = crypto.createHmac("sha256", SECRET).update(body).digest("base64url");
|
||||||
|
return `${body}.${sig}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidToken(token: string | undefined): boolean {
|
||||||
|
if (!token) return false;
|
||||||
|
const [body, sig] = token.split(".");
|
||||||
|
if (!body || !sig) return false;
|
||||||
|
const expected = crypto.createHmac("sha256", SECRET).update(body).digest("base64url");
|
||||||
|
if (sig.length !== expected.length) return false;
|
||||||
|
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return false;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(Buffer.from(body, "base64url").toString());
|
||||||
|
return typeof payload.exp === "number" && payload.exp > Date.now();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAdmin(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||||
|
const auth = req.headers.authorization;
|
||||||
|
const token = auth?.startsWith("Bearer ") ? auth.slice("Bearer ".length) : undefined;
|
||||||
|
if (!isValidToken(token)) {
|
||||||
|
reply.code(401).send({ error: "unauthorized" });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Contagem de "usuários online" — em memória, sem persistir no banco (é um
|
||||||
|
// número instantâneo, não histórico). O frontend manda um heartbeat a cada
|
||||||
|
// ~20s enquanto a aba está aberta; consideramos "online" quem mandou um
|
||||||
|
// heartbeat nos últimos 45s (tolera uma falha de rede pontual).
|
||||||
|
const heartbeats = new Map<string, number>();
|
||||||
|
const TTL_MS = 45_000;
|
||||||
|
|
||||||
|
export function touchSession(sessionId: string): void {
|
||||||
|
heartbeats.set(sessionId, Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOnlineCount(): number {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [id, lastSeen] of heartbeats) {
|
||||||
|
if (now - lastSeen > TTL_MS) heartbeats.delete(id);
|
||||||
|
}
|
||||||
|
return heartbeats.size;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { FastifyRequest } from "fastify";
|
||||||
|
import geoip from "geoip-lite";
|
||||||
|
|
||||||
|
// Requer `trustProxy: true` no Fastify (ver server.ts) — aí `req.ip` já
|
||||||
|
// reflete o primeiro IP de X-Forwarded-For, que o nginx interno preenche
|
||||||
|
// (ver nginx/nginx.conf) a partir do que o reverse proxy externo do usuário
|
||||||
|
// mandar. Sem isso, todo mundo apareceria com o IP do container "web".
|
||||||
|
export function resolveIp(req: FastifyRequest): string {
|
||||||
|
return req.ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A porta de origem do cliente normalmente NÃO sobrevive a um reverse
|
||||||
|
// proxy (o socket que a API enxerga é o do nginx, não o do navegador do
|
||||||
|
// visitante) — só é possível saber a porta real se algum proxy na cadeia
|
||||||
|
// mandar um cabeçalho explícito (X-Forwarded-Port não costuma carregar a
|
||||||
|
// porta do cliente, mas deixamos o suporte pronto caso o proxy externo do
|
||||||
|
// usuário seja configurado para isso). Na ausência dele, usamos a porta do
|
||||||
|
// socket TCP direto (a porta efêmera de quem conectou nesse hop).
|
||||||
|
export function resolvePort(req: FastifyRequest): number | null {
|
||||||
|
const header = req.headers["x-forwarded-port"];
|
||||||
|
const value = Array.isArray(header) ? header[0] : header;
|
||||||
|
if (value) {
|
||||||
|
const port = Number(value.split(",")[0]);
|
||||||
|
if (Number.isFinite(port)) return port;
|
||||||
|
}
|
||||||
|
return req.socket?.remotePort ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGeo(ip: string): { country: string | null; city: string | null } {
|
||||||
|
const geo = geoip.lookup(ip);
|
||||||
|
return { country: geo?.country ?? null, city: geo?.city ?? null };
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
import { signAdminToken, requireAdmin } from "../lib/adminAuth.js";
|
||||||
|
import { getOnlineCount } from "../lib/presence.js";
|
||||||
|
|
||||||
|
// Mesmo padrão de "credencial em variável de ambiente com default" já usado
|
||||||
|
// pelo Postgres neste compose (POSTGRES_USER/POSTGRES_PASSWORD) — painel de
|
||||||
|
// uso pessoal (um único admin), sem necessidade de tabela de usuários.
|
||||||
|
const ADMIN_EMAIL = process.env.ADMIN_EMAIL ?? "rodrigo@martinsevieira.adv.br";
|
||||||
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? "34641747";
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string(),
|
||||||
|
password: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function adminRoutes(app: FastifyInstance) {
|
||||||
|
app.post("/api/admin/login", async (req, reply) => {
|
||||||
|
const { email, password } = loginSchema.parse(req.body);
|
||||||
|
if (email !== ADMIN_EMAIL || password !== ADMIN_PASSWORD) {
|
||||||
|
reply.code(401).send({ error: "Credenciais inválidas" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return { token: signAdminToken(email) };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.register(async (protectedApp) => {
|
||||||
|
protectedApp.addHook("preHandler", requireAdmin);
|
||||||
|
|
||||||
|
protectedApp.get("/api/admin/stats", async () => {
|
||||||
|
const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
const since7d = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const [
|
||||||
|
totalVisits,
|
||||||
|
visitsLast24h,
|
||||||
|
visitsLast7d,
|
||||||
|
visitsBySystem,
|
||||||
|
visitsByCountry,
|
||||||
|
recentVisits,
|
||||||
|
feedback,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.pageVisit.count(),
|
||||||
|
prisma.pageVisit.count({ where: { createdAt: { gte: since24h } } }),
|
||||||
|
prisma.pageVisit.count({ where: { createdAt: { gte: since7d } } }),
|
||||||
|
prisma.pageVisit.groupBy({
|
||||||
|
by: ["system"],
|
||||||
|
_count: { system: true },
|
||||||
|
where: { system: { not: null } },
|
||||||
|
orderBy: { _count: { system: "desc" } },
|
||||||
|
}),
|
||||||
|
prisma.pageVisit.groupBy({
|
||||||
|
by: ["country"],
|
||||||
|
_count: { country: true },
|
||||||
|
orderBy: { _count: { country: "desc" } },
|
||||||
|
}),
|
||||||
|
prisma.pageVisit.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 200,
|
||||||
|
}),
|
||||||
|
prisma.feedback.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 200,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
onlineNow: getOnlineCount(),
|
||||||
|
totalVisits,
|
||||||
|
visitsLast24h,
|
||||||
|
visitsLast7d,
|
||||||
|
visitsBySystem: visitsBySystem.map((v) => ({ system: v.system, count: v._count.system })),
|
||||||
|
visitsByCountry: visitsByCountry.map((v) => ({
|
||||||
|
country: v.country ?? "Desconhecido",
|
||||||
|
count: v._count.country,
|
||||||
|
})),
|
||||||
|
recentVisits,
|
||||||
|
feedback,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
import { resolveGeo, resolveIp, resolvePort } from "../lib/requestMeta.js";
|
||||||
|
import { getOnlineCount, touchSession } from "../lib/presence.js";
|
||||||
|
|
||||||
|
const visitSchema = z.object({
|
||||||
|
path: z.string().min(1).max(300),
|
||||||
|
system: z.string().max(100).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const heartbeatSchema = z.object({
|
||||||
|
sessionId: z.string().min(8).max(64),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function analyticsRoutes(app: FastifyInstance) {
|
||||||
|
app.post("/api/analytics/visit", async (req) => {
|
||||||
|
const { path, system } = visitSchema.parse(req.body);
|
||||||
|
const ip = resolveIp(req);
|
||||||
|
const port = resolvePort(req);
|
||||||
|
const { country, city } = resolveGeo(ip);
|
||||||
|
await prisma.pageVisit.create({
|
||||||
|
data: {
|
||||||
|
path,
|
||||||
|
system: system ?? null,
|
||||||
|
ip,
|
||||||
|
port: port ?? undefined,
|
||||||
|
country,
|
||||||
|
city,
|
||||||
|
userAgent: (req.headers["user-agent"] as string | undefined) ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/analytics/heartbeat", async (req) => {
|
||||||
|
const { sessionId } = heartbeatSchema.parse(req.body);
|
||||||
|
touchSession(sessionId);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/analytics/online-count", async () => ({ count: getOnlineCount() }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "../lib/prisma.js";
|
||||||
|
import { resolveIp } from "../lib/requestMeta.js";
|
||||||
|
|
||||||
|
const feedbackSchema = z.object({
|
||||||
|
message: z.string().min(1).max(2000),
|
||||||
|
email: z.string().email().max(200).optional().or(z.literal("")),
|
||||||
|
rating: z.number().int().min(1).max(5).optional(),
|
||||||
|
page: z.string().max(200).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function feedbackRoutes(app: FastifyInstance) {
|
||||||
|
app.post("/api/feedback", async (req, reply) => {
|
||||||
|
const data = feedbackSchema.parse(req.body);
|
||||||
|
await prisma.feedback.create({
|
||||||
|
data: {
|
||||||
|
message: data.message,
|
||||||
|
email: data.email || null,
|
||||||
|
rating: data.rating ?? null,
|
||||||
|
page: data.page ?? null,
|
||||||
|
ip: resolveIp(req),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
reply.code(201);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
+10
-1
@@ -6,11 +6,17 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { systemsRoutes } from "./routes/systems.js";
|
import { systemsRoutes } from "./routes/systems.js";
|
||||||
import { structuresRoutes } from "./routes/structures.js";
|
import { structuresRoutes } from "./routes/structures.js";
|
||||||
import { quizRoutes } from "./routes/quiz.js";
|
import { quizRoutes } from "./routes/quiz.js";
|
||||||
|
import { analyticsRoutes } from "./routes/analytics.js";
|
||||||
|
import { adminRoutes } from "./routes/admin.js";
|
||||||
|
import { feedbackRoutes } from "./routes/feedback.js";
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const MODELS_DIR = process.env.MODELS_DIR ?? path.resolve(__dirname, "../models");
|
const MODELS_DIR = process.env.MODELS_DIR ?? path.resolve(__dirname, "../models");
|
||||||
|
|
||||||
const app = Fastify({ logger: true });
|
// trustProxy: essencial pra "req.ip" refletir o visitante real (via
|
||||||
|
// X-Forwarded-For) em vez do IP do nginx interno — ver nginx/nginx.conf,
|
||||||
|
// que já repassa X-Forwarded-For a partir do reverse proxy externo.
|
||||||
|
const app = Fastify({ logger: true, trustProxy: true });
|
||||||
|
|
||||||
await app.register(cors, { origin: true });
|
await app.register(cors, { origin: true });
|
||||||
|
|
||||||
@@ -27,6 +33,9 @@ app.get("/api/health", async () => ({ status: "ok" }));
|
|||||||
await app.register(systemsRoutes);
|
await app.register(systemsRoutes);
|
||||||
await app.register(structuresRoutes);
|
await app.register(structuresRoutes);
|
||||||
await app.register(quizRoutes);
|
await app.register(quizRoutes);
|
||||||
|
await app.register(analyticsRoutes);
|
||||||
|
await app.register(adminRoutes);
|
||||||
|
await app.register(feedbackRoutes);
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 3333);
|
const port = Number(process.env.PORT ?? 3333);
|
||||||
app
|
app
|
||||||
|
|||||||
@@ -103,23 +103,37 @@ const LEADER_LINE_SOURCE_KEYS = new Set(["nervous", "regions"]);
|
|||||||
|
|
||||||
function isLeaderLineMesh(mesh) {
|
function isLeaderLineMesh(mesh) {
|
||||||
try {
|
try {
|
||||||
const prims = mesh.listPrimitives();
|
// Antes, cada primitivo era julgado sozinho ("every"). Isso deixava
|
||||||
if (prims.length === 0) return false;
|
// passar batido linhas de chamada com um pequeno "gancho"/marcador na
|
||||||
return prims.every((prim) => isLeaderLinePrimitive(prim));
|
// ponta (comum no app original, pra indicar o ponto exato) — esse
|
||||||
|
// gancho é um primitivo à parte que não é colinear, então "every"
|
||||||
|
// reprovava a malha inteira. Agora juntamos os vértices de TODOS os
|
||||||
|
// primitivos da malha num só cálculo e usamos o percentil 90 do desvio
|
||||||
|
// (em vez do máximo), assim uma pontinha que afeta só uma minoria dos
|
||||||
|
// vértices não derruba a classificação, mas uma estrutura real (curva,
|
||||||
|
// ramificada, com volume) continua reprovando.
|
||||||
|
const points = [];
|
||||||
|
for (const prim of mesh.listPrimitives()) {
|
||||||
|
const accessor = prim.getAttribute("POSITION");
|
||||||
|
if (!accessor) continue;
|
||||||
|
const count = accessor.getCount();
|
||||||
|
const arr = accessor.getArray();
|
||||||
|
if (!arr) continue;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
points.push([arr[i * 3], arr[i * 3 + 1], arr[i * 3 + 2]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return isLeaderLinePointCloud(points);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLeaderLinePrimitive(prim) {
|
function isLeaderLinePointCloud(points) {
|
||||||
const accessor = prim.getAttribute("POSITION");
|
const count = points.length;
|
||||||
if (!accessor) return false;
|
|
||||||
const count = accessor.getCount();
|
|
||||||
if (count < 2) return false;
|
if (count < 2) return false;
|
||||||
const arr = accessor.getArray();
|
|
||||||
if (!arr) return false;
|
|
||||||
|
|
||||||
const at = (i) => [arr[i * 3], arr[i * 3 + 1], arr[i * 3 + 2]];
|
const at = (i) => points[i];
|
||||||
const dist2 = (a, b) => {
|
const dist2 = (a, b) => {
|
||||||
const dx = a[0] - b[0], dy = a[1] - b[1], dz = a[2] - b[2];
|
const dx = a[0] - b[0], dy = a[1] - b[1], dz = a[2] - b[2];
|
||||||
return dx * dx + dy * dy + dz * dz;
|
return dx * dx + dy * dy + dz * dz;
|
||||||
@@ -144,18 +158,21 @@ function isLeaderLinePrimitive(prim) {
|
|||||||
if (axisLen < 0.02) return false; // curto demais pra ser uma linha de chamada (poste real de âncora)
|
if (axisLen < 0.02) return false; // curto demais pra ser uma linha de chamada (poste real de âncora)
|
||||||
|
|
||||||
const ax = [(b[0] - a[0]) / axisLen, (b[1] - a[1]) / axisLen, (b[2] - a[2]) / axisLen];
|
const ax = [(b[0] - a[0]) / axisLen, (b[1] - a[1]) / axisLen, (b[2] - a[2]) / axisLen];
|
||||||
let maxPerp = 0;
|
const perps = new Array(count);
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const p = at(i);
|
const p = at(i);
|
||||||
const v = [p[0] - a[0], p[1] - a[1], p[2] - a[2]];
|
const v = [p[0] - a[0], p[1] - a[1], p[2] - a[2]];
|
||||||
const t = v[0] * ax[0] + v[1] * ax[1] + v[2] * ax[2];
|
const t = v[0] * ax[0] + v[1] * ax[1] + v[2] * ax[2];
|
||||||
const perp2 =
|
const perp2 =
|
||||||
(v[0] - t * ax[0]) ** 2 + (v[1] - t * ax[1]) ** 2 + (v[2] - t * ax[2]) ** 2;
|
(v[0] - t * ax[0]) ** 2 + (v[1] - t * ax[1]) ** 2 + (v[2] - t * ax[2]) ** 2;
|
||||||
if (perp2 > maxPerp) maxPerp = perp2;
|
perps[i] = Math.sqrt(perp2);
|
||||||
}
|
}
|
||||||
// "Linha reta e fina": todos os pontos ficam muito perto do próprio eixo
|
perps.sort((x, y) => x - y);
|
||||||
// mais longo (desvio perpendicular pequeno comparado ao comprimento).
|
const p90 = perps[Math.floor(0.9 * (count - 1))];
|
||||||
return Math.sqrt(maxPerp) < axisLen * 0.08;
|
// "Linha reta e fina": 90% dos pontos ficam muito perto do próprio eixo
|
||||||
|
// mais longo (desvio perpendicular pequeno comparado ao comprimento) —
|
||||||
|
// tolera até 10% de vértices "fora da linha" (gancho/marcador na ponta).
|
||||||
|
return p90 < axisLen * 0.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function classifySystem(key, rawName) {
|
function classifySystem(key, rawName) {
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ services:
|
|||||||
DATABASE_URL: postgresql://${POSTGRES_USER:-anatomia}:${POSTGRES_PASSWORD:-anatomia}@anatomia-db:5432/${POSTGRES_DB:-anatomia}?connection_limit=10
|
DATABASE_URL: postgresql://${POSTGRES_USER:-anatomia}:${POSTGRES_PASSWORD:-anatomia}@anatomia-db:5432/${POSTGRES_DB:-anatomia}?connection_limit=10
|
||||||
PORT: 3333
|
PORT: 3333
|
||||||
MODELS_DIR: /app/models
|
MODELS_DIR: /app/models
|
||||||
|
# Login do painel de estatísticas (/estatisticas no frontend).
|
||||||
|
ADMIN_EMAIL: ${ADMIN_EMAIL:-rodrigo@martinsevieira.adv.br}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-34641747}
|
||||||
|
ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET:-troque-este-segredo-em-producao}
|
||||||
volumes:
|
volumes:
|
||||||
- anatomia_models_data:/app/models:ro
|
- anatomia_models_data:/app/models:ro
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import AtlasPage from "./routes/AtlasPage.js";
|
|||||||
import PlanesPage from "./routes/PlanesPage.js";
|
import PlanesPage from "./routes/PlanesPage.js";
|
||||||
import QuizPage from "./routes/QuizPage.js";
|
import QuizPage from "./routes/QuizPage.js";
|
||||||
import PhysicalExamPage from "./routes/PhysicalExamPage.js";
|
import PhysicalExamPage from "./routes/PhysicalExamPage.js";
|
||||||
|
import AdminStatsPage from "./routes/AdminStatsPage.js";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -14,6 +15,8 @@ export default function App() {
|
|||||||
<Route path="/exame-fisico" element={<PhysicalExamPage />} />
|
<Route path="/exame-fisico" element={<PhysicalExamPage />} />
|
||||||
<Route path="/quiz" element={<QuizPage />} />
|
<Route path="/quiz" element={<QuizPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
{/* Fora do Layout: painel interno, sem o menu/nav do atlas. */}
|
||||||
|
<Route path="/estatisticas" element={<AdminStatsPage />} />
|
||||||
</Routes>
|
</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 }) =>
|
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"}`;
|
`px-3 py-1.5 rounded text-sm ${isActive ? "bg-accent text-white" : "text-slate-300 hover:bg-white/5"}`;
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
trackVisit(location.pathname);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[100dvh] flex flex-col">
|
<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">
|
<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
|
Quiz
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
<OnlineCounter />
|
||||||
<a
|
<a
|
||||||
href="https://github.com/LluisV/Z-Anatomy-Sample"
|
href="https://github.com/LluisV/Z-Anatomy-Sample"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -36,6 +49,8 @@ export function Layout() {
|
|||||||
<main className="flex-1 min-h-0">
|
<main className="flex-1 min-h-0">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
<FeedbackWidget />
|
||||||
|
<CookieConsent />
|
||||||
</div>
|
</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;
|
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";
|
const BASE = "/api";
|
||||||
|
|
||||||
async function getJson<T>(url: string): Promise<T> {
|
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>;
|
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 = {
|
export const api = {
|
||||||
systems: () => getJson<AnatomicalSystem[]>(`${BASE}/systems`),
|
systems: () => getJson<AnatomicalSystem[]>(`${BASE}/systems`),
|
||||||
|
|
||||||
@@ -72,6 +121,28 @@ export const api = {
|
|||||||
if (!res.ok) throw new Error(`quizReview failed: ${res.status}`);
|
if (!res.ok) throw new Error(`quizReview failed: ${res.status}`);
|
||||||
return res.json();
|
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 {
|
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 { StructureInfoPanel } from "../components/StructureInfoPanel.js";
|
||||||
import { QuickRegionNav } from "../components/QuickRegionNav.js";
|
import { QuickRegionNav } from "../components/QuickRegionNav.js";
|
||||||
import { SkinDiagram } from "../components/SkinDiagram.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
|
// 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
|
// laterais viram gavetas (drawers) que deslizam por cima do visualizador
|
||||||
@@ -27,6 +28,13 @@ export default function AtlasPage() {
|
|||||||
}
|
}
|
||||||
}, [selectedStructureId]);
|
}, [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 (
|
return (
|
||||||
<div className="h-full relative md:grid md:grid-cols-[280px_1fr_320px]">
|
<div className="h-full relative md:grid md:grid-cols-[280px_1fr_320px]">
|
||||||
<aside
|
<aside
|
||||||
|
|||||||
Reference in new issue
Block a user