feat: metricas de popularidad - contador de lecturas con dedupe, hero carrusel (ultimo/mas leido/viral), widgets top + trending
This commit is contained in:
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user