diff --git a/src/app/(public)/p/[slug]/page.tsx b/src/app/(public)/p/[slug]/page.tsx index d1c2f5e..7fd0d8f 100644 --- a/src/app/(public)/p/[slug]/page.tsx +++ b/src/app/(public)/p/[slug]/page.tsx @@ -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 (
+ {/* Registra la vista al abrir el post (dedupe 1/día por visitante) */} + 0 && ( {post.num_pages} páginas )} + {views > 0 && ( + + + {views} {views === 1 ? "lectura" : "lecturas"} + + )}

{title}

{post.title_jpn && ( diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx index 073d56c..5e443b9 100644 --- a/src/app/(public)/page.tsx +++ b/src/app/(public)/page.tsx @@ -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() - 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 (
- {/* ===== HERO: publicación destacada (hook visual inmediato) ===== */} + {/* ===== HERO CARRUSEL: último lanzamiento → más leído → más viral ===== */} {latest && ( -
- -
- {latest.title} -
- - Último lanzamiento -
-
-
-
- - Destacado - - {latest.num_pages > 0 && ( - {latest.num_pages} páginas - )} -
-

- {displayTitle(latest)} -

- {latest.artist && ( -

por {latest.artist}

- )} - {latest.summary && latest.summary.length > 40 && ( -

- {latest.summary} -

- )} -
- - - Leer - - -
-
- -
+ )} {/* ===== Chips de tags populares (navegación por interés) ===== */} @@ -97,7 +62,86 @@ export default async function PublicHomePage() { )} - {/* ===== Feed central: masonry + scroll infinito ===== */} + {/* ===== Widgets de popularidad ===== */} + {(mostViewed.length > 0 || trending.length > 0) && ( +
+ {mostViewed.length > 0 && ( +
+

+ + Lo más leído +

+
    + {mostViewed.slice(0, 5).map((p, i) => ( +
  • + + + {i + 1} + + +
    +

    + {displayTitle(p)} +

    +

    + {p.views} {p.views === 1 ? "lectura" : "lecturas"} +

    +
    + +
  • + ))} +
+
+ )} + + {trending.length > 0 && ( +
+

+ + Lo más viral esta semana +

+
    + {trending.slice(0, 5).map((p, i) => ( +
  • + + + {i + 1} + + +
    +

    + {displayTitle(p)} +

    +

    + {p.views} {p.views === 1 ? "lectura" : "lecturas"} esta semana +

    +
    + +
  • + ))} +
+
+ )} +
+ )} + + {/* ===== Feed central: grid + scroll infinito ===== */}
diff --git a/src/app/api/stats/view/route.ts b/src/app/api/stats/view/route.ts new file mode 100644 index 0000000..7ddccde --- /dev/null +++ b/src/app/api/stats/view/route.ts @@ -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_ 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 +} \ No newline at end of file diff --git a/src/components/hero-carousel.tsx b/src/components/hero-carousel.tsx new file mode 100644 index 0000000..0da45f8 --- /dev/null +++ b/src/components/hero-carousel.tsx @@ -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(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 ( +
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 */} +
+ {slides.map((s) => ( + +
+ {displayTitle(s.post)} +
+ {s.icon === "flame" && } + {s.icon === "trending" && } + {s.icon === "sparkles" && } + {s.label} +
+
+
+
+ {s.post.views > 0 && ( + + + {s.post.views} {s.post.views === 1 ? "lectura" : "lecturas"} + + )} + {s.post.num_pages > 0 && ( + {s.post.num_pages} páginas + )} +
+

+ {displayTitle(s.post)} +

+ {s.post.artist && ( +

por {s.post.artist}

+ )} +
+ + + Leer + + +
+
+ + ))} +
+ + {/* Flechas (desktop) */} + {slides.length > 1 && ( + <> + + + + )} + + {/* Indicadores */} + {slides.length > 1 && ( +
+ {slides.map((s, i) => ( +
+ )} +
+ ) +} \ No newline at end of file diff --git a/src/components/view-tracker.tsx b/src/components/view-tracker.tsx new file mode 100644 index 0000000..9c4e861 --- /dev/null +++ b/src/components/view-tracker.tsx @@ -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 +} \ No newline at end of file diff --git a/src/lib/db.ts b/src/lib/db.ts index 3e159c2..f4d8ddb 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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[] + 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[] + 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 +} diff --git a/src/middleware.ts b/src/middleware.ts index 4c399a1..dbcf128 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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 }