diff --git a/analyze_data.js b/analyze_data.js new file mode 100644 index 0000000..6a6f75b --- /dev/null +++ b/analyze_data.js @@ -0,0 +1,61 @@ +const Database = require("better-sqlite3"); +const db = new Database("./data/worst-scan.db", { readonly: true }); + +const posts = db.prepare("SELECT gid, title, title_jpn, artist, parody, tags FROM posts WHERE published=1").all(); + +// Análisis de artistas +const artists = {}; +// Análisis de parodias +const parodias = {}; +// Detección de series por título base (quitar capítulos/volúmenes/números) +const seriesByTitle = {}; + +function normalizarSerie(title) { + if (!title) return null; + // Quitar marcadores de capítulo/volumen + let t = title + .replace(/[\[\(].*?[\]\)]/g, "") // quitar corchetes [..] y paréntesis (..) + .replace(/\b(chapter|ch|cap|tomo|vol|volume|part|#)\s*\.?\s*\d+[a-z]?\b/gi, "") + .replace(/\b\d+\s*(chapter|ch|cap|tomo|vol|volume|part)\b/gi, "") + .replace(/\b\d+\b/g, "") // números sueltos + .replace(/[^a-z0-9áéíóúüñ\s]/gi, "") + .trim() + .toLowerCase(); + t = t.replace(/\s+/g, " ").trim(); + return t.length >= 4 ? t : null; +} + +for (const p of posts) { + const artist = (p.artist || "sin artista").trim(); + artists[artist] = (artists[artist] || 0) + 1; + + const parody = (p.parody || "sin parodia").trim(); + parodias[parody] = (parodias[parody] || 0) + 1; + + const serie = normalizarSerie(p.title); + if (serie) { + if (!seriesByTitle[serie]) seriesByTitle[serie] = []; + seriesByTitle[serie].push(p.title.slice(0, 60)); + } +} + +console.log("=== TOTAL POSTS PUBLICADOS ===", posts.length); +console.log("\n=== ARTISTAS (con >1 obra) ==="); +Object.entries(artists).filter(([,c]) => c > 1).sort((a,b) => b[1]-a[1]).forEach(([a,c]) => console.log(` ${a}: ${c} obras`)); +console.log("Artistas unicos:", Object.keys(artists).length); + +console.log("\n=== PARODIAS (franquicias, con >1 obra) ==="); +Object.entries(parodias).filter(([,c]) => c > 1).sort((a,b) => b[1]-a[1]).forEach(([a,c]) => console.log(` ${a}: ${c} obras`)); + +console.log("\n=== POSIBLES SERIES (título base repetido, >1 obra) ==="); +const seriesMulti = Object.entries(seriesByTitle).filter(([,t]) => t.length > 1); +if (seriesMulti.length === 0) { + console.log(" Ninguna serie con título base repetido detectada"); +} else { + seriesMulti.sort((a,b) => b[1].length - a[1].length).forEach(([s, titles]) => { + console.log(` SERIE: "${s}" (${titles.length})`); + titles.forEach(t => console.log(` - ${t}`)); + }); +} + +db.close(); \ No newline at end of file diff --git a/populate_authors.js b/populate_authors.js new file mode 100644 index 0000000..d964e59 --- /dev/null +++ b/populate_authors.js @@ -0,0 +1,59 @@ +const Database = require("better-sqlite3"); +const db = new Database("./data/worst-scan.db"); + +// Crear tablas de autores/series (misma migracion que src/lib/db.ts) +try { + db.exec(` + CREATE TABLE IF NOT EXISTS authors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + slug TEXT UNIQUE NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS series ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + slug TEXT UNIQUE NOT NULL, + description TEXT, + cover_url TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_authors_slug ON authors(slug); + CREATE INDEX IF NOT EXISTS idx_series_slug ON series(slug); + ALTER TABLE posts ADD COLUMN series_id INTEGER REFERENCES series(id); + `); + try { db.exec("CREATE INDEX IF NOT EXISTS idx_posts_series ON posts(series_id)"); } catch {} +} catch (e) { console.log("migracion:", e.message); } + +// helper slugify (mismo que src/lib/slug.ts) +function slugify(...parts) { + return parts + .join("-") + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80) || "sin-titulo"; +} + +// 1) Poblar autores desde los artist de posts publicados +const artists = db.prepare("SELECT DISTINCT artist FROM posts WHERE published=1 AND artist IS NOT NULL AND artist != '' AND artist != 'sin artista' AND artist != 'dummy'").all(); +const upsertAuthor = db.prepare("INSERT OR IGNORE INTO authors(name, slug) VALUES (?, ?)"); +let n = 0; +for (const { artist } of artists) { + const r = upsertAuthor.run(artist, slugify(artist)); + if (r.changes > 0) n++; +} +console.log("Autores insertados:", n); + +// 2) Verificar +const total = db.prepare("SELECT COUNT(*) c FROM authors").get(); +console.log("Total autores en tabla:", total.c); + +// 3) Verificar series (deberian ser 0 por ahora) +const series = db.prepare("SELECT COUNT(*) c FROM series").get(); +console.log("Total series:", series.c); + +db.close(); \ No newline at end of file diff --git a/src/app/(public)/autor/[slug]/page.tsx b/src/app/(public)/autor/[slug]/page.tsx new file mode 100644 index 0000000..e1f2c00 --- /dev/null +++ b/src/app/(public)/autor/[slug]/page.tsx @@ -0,0 +1,67 @@ +import { notFound } from "next/navigation" +import Link from "next/link" +import type { Metadata } from "next" +import { getAuthorBySlug, getPostsByAuthor } from "@/lib/db" +import { PostCard } from "@/components/post-card" +import { EmptyState } from "@/components/empty-state" +import { ArrowLeft, User } from "lucide-react" + +export const dynamic = "force-dynamic" + +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }> +}): Promise { + const { slug } = await params + const author = getAuthorBySlug(slug) + if (!author) return { title: "No encontrado" } + return { + title: author.name, + description: `Todas las obras de ${author.name} en worst-scan fansub`, + } +} + +export default async function AuthorPage({ + params, +}: { + params: Promise<{ slug: string }> +}) { + const { slug } = await params + const author = getAuthorBySlug(slug) + if (!author) notFound() + + const posts = getPostsByAuthor(author.name) + + return ( +
+ + + Todas las publicaciones + + +
+
+ +

{author.name}

+
+

+ {posts.length} {posts.length === 1 ? "obra publicada" : "obras publicadas"} +

+
+ + {posts.length === 0 ? ( + + ) : ( +
+ {posts.map((post) => ( + + ))} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/src/app/(public)/franquicia/[tag]/page.tsx b/src/app/(public)/franquicia/[tag]/page.tsx new file mode 100644 index 0000000..7771c2f --- /dev/null +++ b/src/app/(public)/franquicia/[tag]/page.tsx @@ -0,0 +1,64 @@ +import { notFound } from "next/navigation" +import Link from "next/link" +import type { Metadata } from "next" +import { getPostsByParody } from "@/lib/db" +import { PostCard } from "@/components/post-card" +import { EmptyState } from "@/components/empty-state" +import { ArrowLeft, Sparkles } from "lucide-react" + +export const dynamic = "force-dynamic" + +export async function generateMetadata({ + params, +}: { + params: Promise<{ tag: string }> +}): Promise { + const { tag } = await params + const name = decodeURIComponent(tag) + return { + title: `${name} — franquicia`, + description: `Mangas de la franquicia ${name} en worst-scan fansub`, + } +} + +export default async function ParodyPage({ + params, +}: { + params: Promise<{ tag: string }> +}) { + const { tag } = await params + const name = decodeURIComponent(tag) + const posts = getPostsByParody(name) + + return ( +
+ + + Todas las publicaciones + + +
+
+ +

{name}

+
+

+ {posts.length} {posts.length === 1 ? "obra" : "obras"} de la franquicia +

+
+ + {posts.length === 0 ? ( + + ) : ( +
+ {posts.map((post) => ( + + ))} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/src/app/(public)/layout.tsx b/src/app/(public)/layout.tsx index 978e2b1..eee014d 100644 --- a/src/app/(public)/layout.tsx +++ b/src/app/(public)/layout.tsx @@ -2,8 +2,10 @@ import Link from "next/link" import { Image, Search, Rss } from "lucide-react" import { SITE_NAME, SITE_TAGLINE, SITE_LINKS } from "@/lib/site" import { AgeGate } from "@/components/age-gate" +import { FanSubSidebar } from "@/components/fansub-sidebar" -// Header del sitio público: búsqueda pública (SQLite) + logo. +// Layout público: header + sidebar de navegación (géneros/autores/franquicias) +// + contenido + footer. export default function PublicLayout({ children }: { children: React.ReactNode }) { return ( <> @@ -29,7 +31,13 @@ export default function PublicLayout({ children }: { children: React.ReactNode } -
{children}
+
+
+ {/* Panel lateral con géneros/autores/franquicias (desktop) */} + +
{children}
+
+