feat: sistema de series/autores/franquicias + sidebar navegacion

This commit is contained in:
Renato
2026-08-05 02:30:53 +08:00
parent c16666035c
commit a237d08f59
10 changed files with 634 additions and 4 deletions
+61
View File
@@ -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();
+59
View File
@@ -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();
+67
View File
@@ -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<Metadata> {
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 (
<div>
<Link
href="/"
className="mb-6 inline-flex items-center gap-1.5 text-xs text-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
>
<ArrowLeft className="h-3.5 w-3.5" />
Todas las publicaciones
</Link>
<div className="mb-8">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-[var(--accent)]" />
<h1 className="text-lg font-bold tracking-tight">{author.name}</h1>
</div>
<p className="mt-1 text-sm text-[var(--muted)]">
{posts.length} {posts.length === 1 ? "obra publicada" : "obras publicadas"}
</p>
</div>
{posts.length === 0 ? (
<EmptyState message="No hay obras publicadas de este autor" />
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
)}
</div>
)
}
@@ -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<Metadata> {
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 (
<div>
<Link
href="/"
className="mb-6 inline-flex items-center gap-1.5 text-xs text-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
>
<ArrowLeft className="h-3.5 w-3.5" />
Todas las publicaciones
</Link>
<div className="mb-8">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-[var(--accent)]" />
<h1 className="text-lg font-bold tracking-tight">{name}</h1>
</div>
<p className="mt-1 text-sm text-[var(--muted)]">
{posts.length} {posts.length === 1 ? "obra" : "obras"} de la franquicia
</p>
</div>
{posts.length === 0 ? (
<EmptyState message="No hay obras de esta franquicia" />
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
)}
</div>
)
}
+10 -2
View File
@@ -2,8 +2,10 @@ import Link from "next/link"
import { Image, Search, Rss } from "lucide-react" import { Image, Search, Rss } from "lucide-react"
import { SITE_NAME, SITE_TAGLINE, SITE_LINKS } from "@/lib/site" import { SITE_NAME, SITE_TAGLINE, SITE_LINKS } from "@/lib/site"
import { AgeGate } from "@/components/age-gate" 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 }) { export default function PublicLayout({ children }: { children: React.ReactNode }) {
return ( return (
<> <>
@@ -29,7 +31,13 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
</div> </div>
</header> </header>
<main className="mx-auto max-w-5xl px-3 py-4 sm:px-4 sm:py-8">{children}</main> <main className="mx-auto max-w-5xl px-3 py-4 sm:px-4 sm:py-8">
<div className="flex gap-8">
{/* Panel lateral con géneros/autores/franquicias (desktop) */}
<FanSubSidebar />
<div className="min-w-0 flex-1">{children}</div>
</div>
</main>
<footer className="border-t border-[var(--border)] py-6 text-center text-xs text-[var(--muted)] mb-12 sm:mb-0"> <footer className="border-t border-[var(--border)] py-6 text-center text-xs text-[var(--muted)] mb-12 sm:mb-0">
<div className="mx-auto max-w-5xl px-3 flex flex-col items-center gap-3"> <div className="mx-auto max-w-5xl px-3 flex flex-col items-center gap-3">
+85
View File
@@ -0,0 +1,85 @@
import { notFound } from "next/navigation"
import Link from "next/link"
import type { Metadata } from "next"
import { getSeriesBySlug, getPostsBySeries } from "@/lib/db"
import { PostCard } from "@/components/post-card"
import { EmptyState } from "@/components/empty-state"
import { ArrowLeft, Layers } from "lucide-react"
import { CoverImage } from "@/components/cover-image"
export const dynamic = "force-dynamic"
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params
const series = getSeriesBySlug(slug)
if (!series) return { title: "No encontrado" }
return {
title: series.name,
description: series.description || `Todas las obras de ${series.name} en worst-scan fansub`,
}
}
export default async function SeriesPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const series = getSeriesBySlug(slug)
if (!series) notFound()
const posts = getPostsBySeries(series.id)
return (
<div>
<Link
href="/"
className="mb-6 inline-flex items-center gap-1.5 text-xs text-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
>
<ArrowLeft className="h-3.5 w-3.5" />
Todas las publicaciones
</Link>
<div className="mb-8 flex gap-5">
{series.cover_url && (
<div className="h-40 w-28 flex-shrink-0 overflow-hidden rounded-lg border border-[var(--border)]">
<CoverImage gid={extractGid(series.cover_url)} alt={series.name} />
</div>
)}
<div>
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-[var(--accent)]" />
<h1 className="text-xl font-bold tracking-tight">{series.name}</h1>
</div>
<p className="mt-1 text-sm text-[var(--muted)]">
{posts.length} {posts.length === 1 ? "obra" : "obras"}
</p>
{series.description && (
<p className="mt-3 max-w-2xl text-sm text-[var(--muted)] leading-relaxed">
{series.description}
</p>
)}
</div>
</div>
{posts.length === 0 ? (
<EmptyState message="No hay obras publicadas en esta serie" />
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
)}
</div>
)
}
function extractGid(url: string): string {
const m = url.match(/cover\/(\d+)/)
return m ? m[1] : url
}
+83
View File
@@ -0,0 +1,83 @@
import Link from "next/link"
import { getTagsWithCounts, getAuthorsWithWorks, getParodies } from "@/lib/db"
import { Tag, User, Sparkles } from "lucide-react"
// Panel lateral con las entidades de fansub: géneros (tags), autores y
// franquicias, con contadores. Persistente en todas las páginas públicas.
export function FanSubSidebar() {
const tags = getTagsWithCounts(12)
const authors = getAuthorsWithWorks(8)
const parodies = getParodies(8)
return (
<aside className="hidden w-60 flex-shrink-0 flex-col gap-6 lg:flex">
{/* Géneros */}
{tags.length > 0 && (
<div>
<h3 className="mb-2.5 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-[var(--muted)]">
<Tag className="h-3 w-3" />
Géneros
</h3>
<div className="flex flex-wrap gap-1.5">
{tags.map((t) => (
<Link
key={t.tag}
href={`/tag/${encodeURIComponent(t.tag)}`}
className="rounded-full border border-[var(--border)] bg-[var(--surface)] px-2.5 py-1 text-[11px] text-[var(--muted)] transition-colors hover:border-[var(--accent)] hover:text-[var(--accent)]"
>
{t.tag}
<span className="ml-1 text-[10px] text-[var(--muted)]/60">{t.count}</span>
</Link>
))}
</div>
</div>
)}
{/* Autores */}
{authors.length > 0 && (
<div>
<h3 className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-[var(--muted)]">
<User className="h-3 w-3" />
Autores
</h3>
<ul className="space-y-1">
{authors.map((a) => (
<li key={a.id}>
<Link
href={`/autor/${a.slug}`}
className="flex items-center justify-between rounded-md px-2 py-1.5 text-sm text-[var(--muted)] transition-colors hover:bg-[var(--surface)] hover:text-[var(--foreground)]"
>
<span className="truncate">{a.name}</span>
<span className="ml-2 text-[11px] text-[var(--muted)]/60">{a.works}</span>
</Link>
</li>
))}
</ul>
</div>
)}
{/* Franquicias */}
{parodies.length > 0 && (
<div>
<h3 className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-[var(--muted)]">
<Sparkles className="h-3 w-3" />
Franquicias
</h3>
<ul className="space-y-1">
{parodies.map((p) => (
<li key={p.name}>
<Link
href={`/franquicia/${encodeURIComponent(p.name)}`}
className="flex items-center justify-between rounded-md px-2 py-1.5 text-sm text-[var(--muted)] transition-colors hover:bg-[var(--surface)] hover:text-[var(--foreground)]"
>
<span className="truncate">{p.name}</span>
<span className="ml-2 text-[11px] text-[var(--muted)]/60">{p.works}</span>
</Link>
</li>
))}
</ul>
</div>
)}
</aside>
)
}
+203
View File
@@ -1,6 +1,7 @@
import Database from "better-sqlite3" import Database from "better-sqlite3"
import path from "path" import path from "path"
import fs from "fs" import fs from "fs"
import { slugify } from "./slug"
const DB_PATH = process.env.DB_PATH || path.join(process.cwd(), "data", "worst-scan.db") const DB_PATH = process.env.DB_PATH || path.join(process.cwd(), "data", "worst-scan.db")
@@ -52,6 +53,27 @@ function migrate(db: Database.Database) {
content_rowid='id', content_rowid='id',
tokenize='unicode61' tokenize='unicode61'
); );
-- ===== Entidades de fansub: series y autores =====
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);
`) `)
// Triggers de sincronización FTS (solo si no existen). // Triggers de sincronización FTS (solo si no existen).
@@ -84,6 +106,12 @@ function migrate(db: Database.Database) {
try { try {
db.exec("ALTER TABLE posts ADD COLUMN url TEXT") db.exec("ALTER TABLE posts ADD COLUMN url TEXT")
} catch {} } catch {}
// series_id (FK) para agrupar capítulos/obras de una serie.
try {
db.exec("ALTER TABLE posts ADD COLUMN series_id INTEGER REFERENCES series(id)")
db.exec("CREATE INDEX IF NOT EXISTS idx_posts_series ON posts(series_id)")
} catch {}
} }
export interface Post { export interface Post {
@@ -159,6 +187,18 @@ export function createPost(input: PostInput): Post {
const d = getDb() const d = getDb()
const published = input.published ?? 1 const published = input.published ?? 1
const publishedAt = published ? "datetime('now')" : null const publishedAt = published ? "datetime('now')" : null
// Auto-registrar el autor en la tabla authors (para páginas de autor).
if (input.artist) {
const clean = input.artist.trim()
if (clean && clean !== "sin artista" && clean !== "dummy") {
d.prepare("INSERT OR IGNORE INTO authors(name, slug) VALUES (?, ?)").run(
clean,
slugify(clean),
)
}
}
const stmt = d.prepare(` const stmt = d.prepare(`
INSERT INTO posts (gid, title, title_jpn, artist, parody, tags, num_pages, source, cover_url, url, summary, slug, published, published_at) INSERT INTO posts (gid, title, title_jpn, artist, parody, tags, num_pages, source, cover_url, url, summary, slug, published, published_at)
VALUES (@gid, @title, @title_jpn, @artist, @parody, @tags, @num_pages, @source, @cover_url, @url, @summary, @slug, @published, ${publishedAt}) VALUES (@gid, @title, @title_jpn, @artist, @parody, @tags, @num_pages, @source, @cover_url, @url, @summary, @slug, @published, ${publishedAt})
@@ -295,3 +335,166 @@ export function getPostsPage(page: number, perPage: number, publishedOnly = true
export function getGidsNotInPosts(): string[] { export function getGidsNotInPosts(): string[] {
return [] return []
} }
// ===== Entidades de fansub: autores y series =====
export interface Author {
id: number
name: string
slug: string
created_at: string
works: number // cantidad de obras (calculada)
}
export interface Series {
id: number
name: string
slug: string
description: string | null
cover_url: string | null
created_at: string
updated_at: string
works: number // cantidad de capítulos/obras (calculada)
}
function rowToAuthor(row: Record<string, unknown>): Author {
return {
id: row.id as number,
name: row.name as string,
slug: row.slug as string,
created_at: row.created_at as string,
works: (row.works as number) || 0,
}
}
function rowToSeries(row: Record<string, unknown>): Series {
return {
id: row.id as number,
name: row.name as string,
slug: row.slug as string,
description: row.description as string | null,
cover_url: row.cover_url as string | null,
created_at: row.created_at as string,
updated_at: row.updated_at as string,
works: (row.works as number) || 0,
}
}
// Autores con contador de obras publicadas (para el panel lateral).
export function getAuthorsWithWorks(limit = 20): Author[] {
const d = getDb()
const rows = d
.prepare(
`SELECT a.*, (SELECT COUNT(*) FROM posts p WHERE p.published = 1 AND p.artist = a.name) as works
FROM authors a
WHERE a.name IS NOT NULL AND a.name != '' AND a.name != 'sin artista' AND a.name != 'dummy'
ORDER BY works DESC, a.name ASC
LIMIT @limit`,
)
.all({ limit }) as Record<string, unknown>[]
return rows.map(rowToAuthor)
}
// Series con contador de obras publicadas.
export function getSeriesWithWorks(limit = 30): Series[] {
const d = getDb()
const rows = d
.prepare(
`SELECT s.*, (SELECT COUNT(*) FROM posts p WHERE p.published = 1 AND p.series_id = s.id) as works
FROM series s
ORDER BY works DESC, s.name ASC
LIMIT @limit`,
)
.all({ limit }) as Record<string, unknown>[]
return rows.map(rowToSeries)
}
export function getAuthorBySlug(slug: string): Author | null {
const d = getDb()
const row = d
.prepare(
`SELECT a.*, (SELECT COUNT(*) FROM posts p WHERE p.published = 1 AND p.artist = a.name) as works
FROM authors a WHERE a.slug = ?`,
)
.get(slug) as Record<string, unknown> | undefined
return row ? rowToAuthor(row) : null
}
export function getSeriesBySlug(slug: string): Series | null {
const d = getDb()
const row = d
.prepare(
`SELECT s.*, (SELECT COUNT(*) FROM posts p WHERE p.published = 1 AND p.series_id = s.id) as works
FROM series s WHERE s.slug = ?`,
)
.get(slug) as Record<string, unknown> | undefined
return row ? rowToSeries(row) : null
}
// Posts de un autor (todas sus obras publicadas).
export function getPostsByAuthor(authorName: string): Post[] {
const d = getDb()
const rows = d
.prepare(
`SELECT * FROM posts WHERE published = 1 AND artist = ? ORDER BY published_at DESC`,
)
.all(authorName) as Record<string, unknown>[]
return rows.map(rowToPost)
}
// Posts de una serie (capítulos/obras agrupados por serie_id).
export function getPostsBySeries(seriesId: number): Post[] {
const d = getDb()
const rows = d
.prepare(
`SELECT * FROM posts WHERE published = 1 AND series_id = ? ORDER BY published_at ASC`,
)
.all(seriesId) as Record<string, unknown>[]
return rows.map(rowToPost)
}
// Franquicias/parodias con contador (para el panel lateral).
export function getParodies(limit = 20): { name: string; works: number }[] {
const d = getDb()
const rows = d
.prepare(
`SELECT COALESCE(parody, 'sin parodia') as name, COUNT(*) as works
FROM posts WHERE published = 1
GROUP BY parody
HAVING parody IS NOT NULL AND parody != '' AND parody != 'sin parodia' AND parody != 'original'
ORDER BY works DESC, name ASC
LIMIT @limit`,
)
.all({ limit }) as { name: string; works: number }[]
return rows
}
export function getPostsByParody(parody: string): Post[] {
const d = getDb()
const rows = d
.prepare(
`SELECT * FROM posts WHERE published = 1 AND parody = ? ORDER BY published_at DESC`,
)
.all(parody) as Record<string, unknown>[]
return rows.map(rowToPost)
}
// Tags con contador (panel lateral). Usa json_each para aplanar el JSON de tags.
export function getTagsWithCounts(limit = 25): { tag: string; count: number }[] {
const d = getDb()
try {
const rows = d
.prepare(
`SELECT value as tag, COUNT(*) as count
FROM posts, json_each(posts.tags)
WHERE posts.published = 1
GROUP BY value
ORDER BY count DESC, value ASC
LIMIT @limit`,
)
.all({ limit }) as { tag: string; count: number }[]
return rows
} catch {
return []
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
// Discord y Patreon reales. // Discord y Patreon reales.
export const SITE_LINKS = { export const SITE_LINKS = {
twitter: "https://x.com/worstscan", twitter: "https://x.com/worstscan",
discord: "#", // TODO: link real de Discord discord: "https://discord.gg/r5f7BUQ3y",
patreon: "#", // TODO: link real de Patreon patreon: "#", // TODO: link real de Patreon
} }
+1 -1
View File
@@ -26,7 +26,7 @@ const publicPrefixes = [
// "/" es MATCH EXACTO (startsWith("/") matchearía TODO). // "/" es MATCH EXACTO (startsWith("/") matchearía TODO).
// "/gallery/" incluye el lector: cualquiera puede LEER (GET); las acciones // "/gallery/" incluye el lector: cualquiera puede LEER (GET); las acciones
// del panel (eliminar, etc.) quedan protegidas por el middleware vía método. // del panel (eliminar, etc.) quedan protegidas por el middleware vía método.
const publicPages = ["/p/", "/tag/", "/gallery/", "/buscar"] const publicPages = ["/p/", "/tag/", "/gallery/", "/buscar", "/autor/", "/serie/", "/franquicia/"]
// GET de lectura del proxy hacia el pipeline: solo las rutas que el LECTOR // GET de lectura del proxy hacia el pipeline: solo las rutas que el LECTOR
// público necesita (páginas, summary, artifacts, cover). /status y /queue // público necesita (páginas, summary, artifacts, cover). /status y /queue