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 Link from "next/link"
|
||||
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 { TagBadge } from "@/components/tag-badge"
|
||||
import { ViewTracker } from "@/components/view-tracker"
|
||||
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"
|
||||
|
||||
@@ -85,8 +86,13 @@ export default async function PostDetailPage({
|
||||
// Título a mostrar (traducción al español si existe).
|
||||
const title = displayTitle(post)
|
||||
|
||||
// Vistas del post (para el contador).
|
||||
const views = getPostViews(post.gid)
|
||||
|
||||
return (
|
||||
<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
|
||||
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"
|
||||
@@ -116,6 +122,12 @@ export default async function PostDetailPage({
|
||||
{post.num_pages > 0 && (
|
||||
<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>
|
||||
<h1 className="text-lg sm:text-xl font-bold leading-tight">{title}</h1>
|
||||
{post.title_jpn && (
|
||||
|
||||
+93
-49
@@ -1,20 +1,27 @@
|
||||
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 { TagBadge } from "@/components/tag-badge"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { HeroCarousel } from "@/components/hero-carousel"
|
||||
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 default async function PublicHomePage() {
|
||||
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.
|
||||
const tagCounts = new Map<string, number>()
|
||||
for (const p of getAllPosts(true)) {
|
||||
for (const p of allPosts) {
|
||||
for (const t of p.tags || []) {
|
||||
const clean = t.toLowerCase().replace(/^artist:|^parody:/, "").trim()
|
||||
if (!clean || clean.includes(" ")) continue
|
||||
@@ -36,51 +43,9 @@ export default async function PublicHomePage() {
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<section className="rounded-3xl overflow-hidden border border-[var(--border)] bg-gradient-to-br from-[var(--surface)] via-[var(--surface)] to-[#1a1a20]">
|
||||
<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>
|
||||
<HeroCarousel latest={latest} mostViewed={mostViewed} trending={trending} />
|
||||
)}
|
||||
|
||||
{/* ===== Chips de tags populares (navegación por interés) ===== */}
|
||||
@@ -97,7 +62,86 @@ export default async function PublicHomePage() {
|
||||
</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>
|
||||
<div className="mb-4 flex items-end justify-between">
|
||||
<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 {
|
||||
db.exec("ALTER TABLE posts ADD COLUMN title_es TEXT")
|
||||
} 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 {
|
||||
@@ -534,3 +547,53 @@ export function getTagsWithCounts(limit = 25): { tag: string; count: number }[]
|
||||
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.
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user