feat: metricas de popularidad - contador de lecturas con dedupe, hero carrusel (ultimo/mas leido/viral), widgets top + trending

This commit is contained in:
Renato
2026-08-05 07:55:48 +08:00
parent 77ce869ed9
commit 0cf271e4f1
7 changed files with 381 additions and 51 deletions
+14 -2
View File
@@ -1,11 +1,12 @@
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import type { Metadata } from "next" import type { Metadata } from "next"
import { getPostBySlug, getAllPosts, getAuthorSlugByName } from "@/lib/db" import { getPostBySlug, getAllPosts, getAuthorSlugByName, getPostViews } from "@/lib/db"
import { PostCard } from "@/components/post-card" import { PostCard } from "@/components/post-card"
import { TagBadge } from "@/components/tag-badge" import { TagBadge } from "@/components/tag-badge"
import { ViewTracker } from "@/components/view-tracker"
import { displayTitle } from "@/lib/title" import { displayTitle } from "@/lib/title"
import { ArrowLeft, BookOpen, ExternalLink } from "lucide-react" import { ArrowLeft, BookOpen, ExternalLink, Flame } from "lucide-react"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
@@ -85,8 +86,13 @@ export default async function PostDetailPage({
// Título a mostrar (traducción al español si existe). // Título a mostrar (traducción al español si existe).
const title = displayTitle(post) const title = displayTitle(post)
// Vistas del post (para el contador).
const views = getPostViews(post.gid)
return ( return (
<div className="pb-16 md:pb-0"> <div className="pb-16 md:pb-0">
{/* Registra la vista al abrir el post (dedupe 1/día por visitante) */}
<ViewTracker gid={post.gid} />
<Link <Link
href="/" href="/"
className="mb-4 sm:mb-6 inline-flex items-center gap-1.5 text-xs text-[var(--muted)] hover:text-[var(--foreground)] transition-colors" className="mb-4 sm:mb-6 inline-flex items-center gap-1.5 text-xs text-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
@@ -116,6 +122,12 @@ export default async function PostDetailPage({
{post.num_pages > 0 && ( {post.num_pages > 0 && (
<span className="text-xs text-[var(--muted)]">{post.num_pages} páginas</span> <span className="text-xs text-[var(--muted)]">{post.num_pages} páginas</span>
)} )}
{views > 0 && (
<span className="inline-flex items-center gap-1 text-xs text-[var(--muted)]">
<Flame className="h-3 w-3 text-orange-500" />
{views} {views === 1 ? "lectura" : "lecturas"}
</span>
)}
</div> </div>
<h1 className="text-lg sm:text-xl font-bold leading-tight">{title}</h1> <h1 className="text-lg sm:text-xl font-bold leading-tight">{title}</h1>
{post.title_jpn && ( {post.title_jpn && (
+93 -49
View File
@@ -1,20 +1,27 @@
import Link from "next/link" import Link from "next/link"
import { getPostsPage, getAllPosts } from "@/lib/db" import { getPostsPage, getAllPosts, getMostViewedPosts, getTrendingPosts } from "@/lib/db"
import { InfinitePostGrid } from "@/components/infinite-grid" import { InfinitePostGrid } from "@/components/infinite-grid"
import { TagBadge } from "@/components/tag-badge" import { TagBadge } from "@/components/tag-badge"
import { EmptyState } from "@/components/empty-state" import { EmptyState } from "@/components/empty-state"
import { HeroCarousel } from "@/components/hero-carousel"
import { displayTitle } from "@/lib/title" import { displayTitle } from "@/lib/title"
import { BookOpen, Sparkles, ArrowRight } from "lucide-react" import { BookOpen, Flame, TrendingUp } from "lucide-react"
import type { ViewedPost } from "@/lib/db"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
export default async function PublicHomePage() { export default async function PublicHomePage() {
const { items, total } = getPostsPage(1, 12, true) const { items, total } = getPostsPage(1, 12, true)
const latest = getAllPosts(true)[0] // publicación destacada del hero const allPosts = getAllPosts(true)
const latest = allPosts[0] as ViewedPost | undefined // publicación destacada del hero
// Métricas de popularidad: leídos (total) + virales (últimos 7 días)
const mostViewed = getMostViewedPosts(8).filter((p) => p.views > 0)
const trending = getTrendingPosts(7, 8).filter((p) => p.views > 0)
// Tags más frecuentes para los chips de exploración rápida. // Tags más frecuentes para los chips de exploración rápida.
const tagCounts = new Map<string, number>() const tagCounts = new Map<string, number>()
for (const p of getAllPosts(true)) { for (const p of allPosts) {
for (const t of p.tags || []) { for (const t of p.tags || []) {
const clean = t.toLowerCase().replace(/^artist:|^parody:/, "").trim() const clean = t.toLowerCase().replace(/^artist:|^parody:/, "").trim()
if (!clean || clean.includes(" ")) continue if (!clean || clean.includes(" ")) continue
@@ -36,51 +43,9 @@ export default async function PublicHomePage() {
return ( return (
<div className="space-y-8 sm:space-y-10"> <div className="space-y-8 sm:space-y-10">
{/* ===== HERO: publicación destacada (hook visual inmediato) ===== */} {/* ===== HERO CARRUSEL: último lanzamiento → más leído → más viral ===== */}
{latest && ( {latest && (
<section className="rounded-3xl overflow-hidden border border-[var(--border)] bg-gradient-to-br from-[var(--surface)] via-[var(--surface)] to-[#1a1a20]"> <HeroCarousel latest={latest} mostViewed={mostViewed} trending={trending} />
<Link href={`/p/${latest.slug}`} className="group grid gap-0 md:grid-cols-2">
<div className="relative aspect-[4/3] md:aspect-auto md:h-full overflow-hidden">
<img
src={`/api/cover/${latest.gid}`}
alt={latest.title}
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.05]"
/>
<div className="absolute top-3 left-3 inline-flex items-center gap-1.5 rounded-full bg-black/80 backdrop-blur-sm px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-[var(--accent)] border border-[var(--accent)]/40">
<Sparkles className="h-3 w-3" />
Último lanzamiento
</div>
</div>
<div className="flex flex-col justify-center gap-4 p-5 sm:p-8">
<div className="flex items-center gap-2">
<span className="rounded-full bg-[var(--accent)]/10 px-2 py-0.5 text-[10px] font-semibold text-[var(--accent)] border border-[var(--accent)]/30">
Destacado
</span>
{latest.num_pages > 0 && (
<span className="text-xs text-[var(--muted)]">{latest.num_pages} páginas</span>
)}
</div>
<h1 className="text-2xl sm:text-4xl font-bold tracking-tight leading-tight line-clamp-3">
{displayTitle(latest)}
</h1>
{latest.artist && (
<p className="text-sm text-[var(--muted)]">por {latest.artist}</p>
)}
{latest.summary && latest.summary.length > 40 && (
<p className="hidden sm:block text-sm text-[var(--muted)] leading-relaxed line-clamp-3">
{latest.summary}
</p>
)}
<div className="flex flex-wrap items-center gap-3 pt-2">
<span className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-6 py-2.5 text-sm font-semibold text-white transition-all group-hover:bg-[var(--accent-hover)] group-hover:scale-[1.02]">
<BookOpen className="h-4 w-4" />
Leer
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</span>
</div>
</div>
</Link>
</section>
)} )}
{/* ===== Chips de tags populares (navegación por interés) ===== */} {/* ===== Chips de tags populares (navegación por interés) ===== */}
@@ -97,7 +62,86 @@ export default async function PublicHomePage() {
</section> </section>
)} )}
{/* ===== Feed central: masonry + scroll infinito ===== */} {/* ===== Widgets de popularidad ===== */}
{(mostViewed.length > 0 || trending.length > 0) && (
<section className="grid gap-6 md:grid-cols-2">
{mostViewed.length > 0 && (
<div>
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-[var(--muted)] uppercase tracking-wider">
<Flame className="h-4 w-4 text-orange-500" />
Lo más leído
</h2>
<ul className="space-y-1.5">
{mostViewed.slice(0, 5).map((p, i) => (
<li key={p.id}>
<Link
href={`/p/${p.slug}`}
className="group flex items-center gap-3 rounded-xl border border-[var(--border)] bg-[var(--surface)] p-2.5 transition-colors hover:border-[var(--border-hover)]"
>
<span className="w-5 flex-shrink-0 text-center text-sm font-bold text-[var(--muted)]">
{i + 1}
</span>
<img
src={`/api/cover/${p.gid}`}
alt=""
className="h-12 w-9 flex-shrink-0 rounded-md object-cover"
loading="lazy"
/>
<div className="min-w-0 flex-1">
<p className="line-clamp-1 text-xs font-medium group-hover:text-[var(--accent)] transition-colors">
{displayTitle(p)}
</p>
<p className="text-[10px] text-[var(--muted)]">
{p.views} {p.views === 1 ? "lectura" : "lecturas"}
</p>
</div>
</Link>
</li>
))}
</ul>
</div>
)}
{trending.length > 0 && (
<div>
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-[var(--muted)] uppercase tracking-wider">
<TrendingUp className="h-4 w-4 text-emerald-500" />
Lo más viral esta semana
</h2>
<ul className="space-y-1.5">
{trending.slice(0, 5).map((p, i) => (
<li key={p.id}>
<Link
href={`/p/${p.slug}`}
className="group flex items-center gap-3 rounded-xl border border-[var(--border)] bg-[var(--surface)] p-2.5 transition-colors hover:border-[var(--border-hover)]"
>
<span className="w-5 flex-shrink-0 text-center text-sm font-bold text-[var(--muted)]">
{i + 1}
</span>
<img
src={`/api/cover/${p.gid}`}
alt=""
className="h-12 w-9 flex-shrink-0 rounded-md object-cover"
loading="lazy"
/>
<div className="min-w-0 flex-1">
<p className="line-clamp-1 text-xs font-medium group-hover:text-[var(--accent)] transition-colors">
{displayTitle(p)}
</p>
<p className="text-[10px] text-[var(--muted)]">
{p.views} {p.views === 1 ? "lectura" : "lecturas"} esta semana
</p>
</div>
</Link>
</li>
))}
</ul>
</div>
)}
</section>
)}
{/* ===== Feed central: grid + scroll infinito ===== */}
<section> <section>
<div className="mb-4 flex items-end justify-between"> <div className="mb-4 flex items-end justify-between">
<div> <div>
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest } from "next/server"
import { recordView } from "@/lib/db"
// POST /api/stats/view — registra una vista de un post.
// Dedupe por cookie: cada visitante cuenta 1 vista por gid por día
// (cookie ws_v_<gid> con expiración 24h). Evita que bots/refrescos inflen.
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => ({}))
const gid = String(body.gid || "").trim()
if (!gid || !/^\d+$/.test(gid)) {
return Response.json({ error: "gid inválido" }, { status: 400 })
}
const cookieName = `ws_v_${gid}`
const already = req.cookies.get(cookieName)?.value
if (already === "1") {
return Response.json({ counted: false })
}
recordView(gid)
const res = Response.json({ counted: true })
res.headers.append(
"Set-Cookie",
`${cookieName}=1; Path=/; Max-Age=${24 * 60 * 60}; SameSite=Lax`,
)
return res
}
+160
View File
@@ -0,0 +1,160 @@
"use client"
import { useEffect, useRef, useState, useCallback } from "react"
import Link from "next/link"
import type { ViewedPost } from "@/lib/db"
import { displayTitle } from "@/lib/title"
import { ArrowRight, BookOpen, Sparkles, Flame, TrendingUp } from "lucide-react"
interface Slide {
key: string
label: string
icon: "sparkles" | "flame" | "trending"
post: ViewedPost
}
// Hero carrusel: desliza entre Último lanzamiento → Lo más leído → Lo más viral.
// Autoplay cada 6s + swipe táctil + flechas. Transición suave (translateX).
export function HeroCarousel({
latest,
mostViewed,
trending,
}: {
latest: ViewedPost
mostViewed: ViewedPost[]
trending: ViewedPost[]
}) {
const [index, setIndex] = useState(0)
const [paused, setPaused] = useState(false)
const touchX = useRef<number | null>(null)
const slides: Slide[] = [
{ key: "latest", label: "Último lanzamiento", icon: "sparkles", post: latest },
]
if (mostViewed.length > 0) {
slides.push({ key: "most-viewed", label: "Lo más leído", icon: "flame", post: mostViewed[0] })
}
if (trending.length > 0 && trending[0].gid !== mostViewed[0]?.gid) {
slides.push({ key: "trending", label: "Lo más viral", icon: "trending", post: trending[0] })
}
const go = useCallback(
(dir: 1 | -1) => setIndex((i) => (i + dir + slides.length) % slides.length),
[slides.length],
)
// Autoplay (pausa al hover/touch)
useEffect(() => {
if (paused || slides.length <= 1) return
const t = setInterval(() => go(1), 6000)
return () => clearInterval(t)
}, [paused, go, slides.length])
if (slides.length === 0) return null
const active = slides[index]
return (
<section
className="relative overflow-hidden rounded-3xl border border-[var(--border)] bg-gradient-to-br from-[var(--surface)] via-[var(--surface)] to-[#1a1a20]"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onTouchStart={(e) => (touchX.current = e.touches[0].clientX)}
onTouchEnd={(e) => {
if (touchX.current === null) return
const delta = e.changedTouches[0].clientX - touchX.current
if (Math.abs(delta) > 40) go(delta < 0 ? 1 : -1) // swipe izq = next, der = prev
touchX.current = null
}}
>
{/* Slides en fila, translateX suave */}
<div
className="flex transition-transform duration-700 ease-in-out"
style={{ transform: `translateX(-${index * 100}%)` }}
>
{slides.map((s) => (
<Link
key={s.key}
href={`/p/${s.post.slug}`}
className="group grid w-full flex-shrink-0 gap-0 md:grid-cols-2"
>
<div className="relative aspect-[4/3] md:aspect-auto md:h-full overflow-hidden">
<img
src={`/api/cover/${s.post.gid}`}
alt={displayTitle(s.post)}
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.05]"
/>
<div className="absolute top-3 left-3 inline-flex items-center gap-1.5 rounded-full bg-black/80 backdrop-blur-sm px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-white border border-white/20">
{s.icon === "flame" && <Flame className="h-3 w-3 text-[#f97316]" />}
{s.icon === "trending" && <TrendingUp className="h-3 w-3 text-emerald-400" />}
{s.icon === "sparkles" && <Sparkles className="h-3 w-3 text-[var(--accent)]" />}
{s.label}
</div>
</div>
<div className="flex flex-col justify-center gap-4 p-5 sm:p-8">
<div className="flex items-center gap-2">
{s.post.views > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--accent)]/10 px-2 py-0.5 text-[10px] font-semibold text-[var(--accent)] border border-[var(--accent)]/30">
<Flame className="h-3 w-3" />
{s.post.views} {s.post.views === 1 ? "lectura" : "lecturas"}
</span>
)}
{s.post.num_pages > 0 && (
<span className="text-xs text-[var(--muted)]">{s.post.num_pages} páginas</span>
)}
</div>
<h1 className="text-2xl sm:text-4xl font-bold tracking-tight leading-tight line-clamp-3">
{displayTitle(s.post)}
</h1>
{s.post.artist && (
<p className="text-sm text-[var(--muted)]">por {s.post.artist}</p>
)}
<div className="flex flex-wrap items-center gap-3 pt-2">
<span className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-6 py-2.5 text-sm font-semibold text-white transition-all group-hover:bg-[var(--accent-hover)] group-hover:scale-[1.02]">
<BookOpen className="h-4 w-4" />
Leer
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</span>
</div>
</div>
</Link>
))}
</div>
{/* Flechas (desktop) */}
{slides.length > 1 && (
<>
<button
onClick={() => go(-1)}
aria-label="Anterior"
className="absolute left-3 top-1/2 hidden -translate-y-1/2 rounded-full bg-black/60 p-2 text-white backdrop-blur-sm transition-all hover:bg-black/80 md:block"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="m15 18-6-6 6-6" /></svg>
</button>
<button
onClick={() => go(1)}
aria-label="Siguiente"
className="absolute right-3 top-1/2 hidden -translate-y-1/2 rounded-full bg-black/60 p-2 text-white backdrop-blur-sm transition-all hover:bg-black/80 md:block"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="m9 18 6-6-6-6" /></svg>
</button>
</>
)}
{/* Indicadores */}
{slides.length > 1 && (
<div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1.5">
{slides.map((s, i) => (
<button
key={s.key}
onClick={() => setIndex(i)}
aria-label={s.label}
className={`h-1.5 rounded-full transition-all ${
i === index ? "w-6 bg-[var(--accent)]" : "w-1.5 bg-white/40 hover:bg-white/70"
}`}
/>
))}
</div>
)}
</section>
)
}
+20
View File
@@ -0,0 +1,20 @@
"use client"
import { useEffect } from "react"
// Registra una vista de un post al abrirlo (dedupe por cookie en el servidor:
// 1 vista por visitante por gid por día). No bloquea ni afecta la UI.
export function ViewTracker({ gid }: { gid: string }) {
useEffect(() => {
if (!gid) return
// Fire-and-forget: no esperamos respuesta, no importa si falla.
fetch("/api/stats/view", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ gid }),
credentials: "same-origin",
}).catch(() => {})
}, [gid])
return null
}
+63
View File
@@ -117,6 +117,19 @@ function migrate(db: Database.Database) {
try { try {
db.exec("ALTER TABLE posts ADD COLUMN title_es TEXT") db.exec("ALTER TABLE posts ADD COLUMN title_es TEXT")
} catch {} } catch {}
// ===== Métricas de popularidad =====
// post_views: cada lectura de un post (1 por visitante por día, dedup por cookie).
db.exec(`
CREATE TABLE IF NOT EXISTS post_views (
id INTEGER PRIMARY KEY AUTOINCREMENT,
gid TEXT NOT NULL,
viewed_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_views_gid ON post_views(gid);
CREATE INDEX IF NOT EXISTS idx_views_date ON post_views(viewed_at);
`)
} }
export interface Post { export interface Post {
@@ -534,3 +547,53 @@ export function getTagsWithCounts(limit = 25): { tag: string; count: number }[]
return [] return []
} }
} }
// ===== Métricas de popularidad =====
export interface ViewedPost extends Post {
views: number
}
// Registra una vista de un post (1 por visitante por día — el dedupe lo hace
// el endpoint vía cookie; acá solo insertamos el evento).
export function recordView(gid: string): void {
const d = getDb()
d.prepare("INSERT INTO post_views (gid) VALUES (?)").run(gid)
}
// Posts más LEÍDOS (total acumulado de vistas).
export function getMostViewedPosts(limit = 10): ViewedPost[] {
const d = getDb()
const rows = d
.prepare(
`SELECT p.*, (SELECT COUNT(*) FROM post_views v WHERE v.gid = p.gid) as views
FROM posts p
WHERE p.published = 1
ORDER BY views DESC
LIMIT @limit`,
)
.all({ limit }) as Record<string, unknown>[]
return rows.map((r) => ({ ...rowToPost(r), views: (r.views as number) || 0 }))
}
// Posts más VIRALES (vistas en los últimos N días) — lo que está explotando ahora.
export function getTrendingPosts(days = 7, limit = 10): ViewedPost[] {
const d = getDb()
const rows = d
.prepare(
`SELECT p.*, (SELECT COUNT(*) FROM post_views v WHERE v.gid = p.gid AND v.viewed_at >= datetime('now', @daysExpr)) as views
FROM posts p
WHERE p.published = 1
ORDER BY views DESC
LIMIT @limit`,
)
.all({ daysExpr: `-${days} days`, limit }) as Record<string, unknown>[]
return rows.map((r) => ({ ...rowToPost(r), views: (r.views as number) || 0 }))
}
// Cantidad de vistas de un gid (para el badge de la card).
export function getPostViews(gid: string): number {
const d = getDb()
const row = d.prepare("SELECT COUNT(*) as c FROM post_views WHERE gid = ?").get(gid) as { c: number }
return row.c
}
+3
View File
@@ -53,6 +53,9 @@ function isPublicPath(pathname: string, method: string): boolean {
// Solo GET; el token valida la autorización dentro del handler. // Solo GET; el token valida la autorización dentro del handler.
if (pathname.startsWith("/api/review/") && method === "GET") return true if (pathname.startsWith("/api/review/") && method === "GET") return true
// /api/stats/view: registro de vistas (POST, gid validado en el handler).
if (pathname === "/api/stats/view" && method === "POST") return true
return false return false
} }