diff --git a/src/app/(public)/layout.tsx b/src/app/(public)/layout.tsx
index 162c159..978e2b1 100644
--- a/src/app/(public)/layout.tsx
+++ b/src/app/(public)/layout.tsx
@@ -1,5 +1,5 @@
import Link from "next/link"
-import { Image, Search } from "lucide-react"
+import { Image, Search, Rss } from "lucide-react"
import { SITE_NAME, SITE_TAGLINE, SITE_LINKS } from "@/lib/site"
import { AgeGate } from "@/components/age-gate"
@@ -37,6 +37,17 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
{SITE_NAME} · {SITE_TAGLINE}
+
+
+ RSS
+
+
|
{SITE_LINKS.twitter && (
<>
+}): Promise {
+ const { slug } = await params
+ const post = getPostBySlug(slug)
+ if (!post) return { title: "No encontrado" }
+
+ const title = post.title
+ const description =
+ post.summary && !post.summary.startsWith("**") && post.summary.length > 40
+ ? post.summary.slice(0, 160)
+ : `${post.num_pages} páginas${post.artist ? ` por ${post.artist}` : ""} — worst-scan fansub`
+
+ return {
+ title,
+ description,
+ openGraph: {
+ title,
+ description,
+ type: "article",
+ url: `https://worstscan.xyz/p/${post.slug}`,
+ images: [{ url: `https://worstscan.xyz/api/cover/${post.gid}`, width: 1200, height: 1600, alt: title }],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title,
+ description,
+ images: [`https://worstscan.xyz/api/cover/${post.gid}`],
+ },
+ }
+}
+
export default async function PostDetailPage({
params,
}: {
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 34d55e4..b9813a1 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -2,8 +2,25 @@ import type { Metadata } from "next"
import "./globals.css"
export const metadata: Metadata = {
- title: "worst-scan",
+ title: {
+ default: "worst-scan",
+ template: "%s — worst-scan",
+ },
description: "worst-scan fansub — escaneos y traducciones de manga",
+ metadataBase: new URL("https://worstscan.xyz"),
+ openGraph: {
+ type: "website",
+ siteName: "worst-scan",
+ title: "worst-scan fansub",
+ description: "Escaneos y traducciones de manga",
+ url: "https://worstscan.xyz",
+ },
+ twitter: {
+ card: "summary_large_image",
+ },
+ icons: {
+ icon: "https://worstscan.xyz/api/cover/306127",
+ },
}
export default function RootLayout({
@@ -11,9 +28,20 @@ export default function RootLayout({
}: Readonly<{ children: React.ReactNode }>) {
return (
+
+ {/* View Transitions: navegación fluida entre páginas (Chrome/Edge/Safari).
+ Degrada a navegación normal en navegadores sin soporte. */}
+
+
{children}
)
-}
+}
\ No newline at end of file
diff --git a/src/app/rss.xml/route.ts b/src/app/rss.xml/route.ts
new file mode 100644
index 0000000..ac33b61
--- /dev/null
+++ b/src/app/rss.xml/route.ts
@@ -0,0 +1,48 @@
+import { getAllPosts } from "@/lib/db"
+
+export const dynamic = "force-dynamic"
+
+// Feed RSS 2.0 del fansub: para que la gente se suscriba y siga los lanzamientos.
+export async function GET() {
+ const posts = getAllPosts(true).slice(0, 30)
+
+ const items = posts
+ .map((p) => {
+ const pubDate = p.published_at
+ ? new Date(p.published_at).toUTCString()
+ : new Date(p.created_at).toUTCString()
+ const description =
+ (p.summary && !p.summary.startsWith("**") && p.summary.length > 30
+ ? p.summary.slice(0, 300)
+ : `${p.num_pages} páginas`) || `${p.num_pages} páginas`
+ return ` -
+
+ https://worstscan.xyz/p/${p.slug}
+ worstscan-${p.gid}
+ ${pubDate}
+
+
+
`
+ })
+ .join("\n")
+
+ const xml = `
+
+
+ worst-scan fansub
+ https://worstscan.xyz
+ Escaneos y traducciones de manga
+ es
+ ${new Date().toUTCString()}
+
+${items}
+
+`
+
+ return new Response(xml, {
+ headers: {
+ "Content-Type": "application/rss+xml; charset=utf-8",
+ "Cache-Control": "public, max-age=600",
+ },
+ })
+}
\ No newline at end of file
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
new file mode 100644
index 0000000..bbce1e8
--- /dev/null
+++ b/src/app/sitemap.ts
@@ -0,0 +1,33 @@
+import { getAllPosts } from "@/lib/db"
+import type { MetadataRoute } from "next"
+
+export const dynamic = "force-dynamic"
+
+// Sitemap para que Google/Bing indexen las páginas públicas.
+export default function sitemap(): MetadataRoute.Sitemap {
+ const posts = getAllPosts(true)
+
+ const urls: MetadataRoute.Sitemap = [
+ { url: "https://worstscan.xyz", lastModified: new Date(), changeFrequency: "daily", priority: 1 },
+ { url: "https://worstscan.xyz/buscar", lastModified: new Date(), changeFrequency: "monthly", priority: 0.5 },
+ ]
+
+ for (const p of posts) {
+ urls.push({
+ url: `https://worstscan.xyz/p/${p.slug}`,
+ lastModified: p.published_at ? new Date(p.published_at) : new Date(p.created_at),
+ changeFrequency: "weekly",
+ priority: 0.8,
+ })
+ for (const tag of p.tags || []) {
+ urls.push({
+ url: `https://worstscan.xyz/tag/${encodeURIComponent(tag)}`,
+ lastModified: new Date(),
+ changeFrequency: "weekly",
+ priority: 0.4,
+ })
+ }
+ }
+
+ return urls
+}
\ No newline at end of file
diff --git a/src/lib/db.ts b/src/lib/db.ts
index 25fad02..893a282 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -43,8 +43,44 @@ function migrate(db: Database.Database) {
CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug);
CREATE INDEX IF NOT EXISTS idx_posts_published ON posts(published);
CREATE INDEX IF NOT EXISTS idx_posts_created ON posts(created_at DESC);
+
+ -- FTS5: búsqueda full-text sobre título/título_jpn/artista/parodia/tags.
+ -- Tabla externa (content=posts) para que se sincronice con los triggers.
+ CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
+ title, title_jpn, artist, parody, tags,
+ content='posts',
+ content_rowid='id',
+ tokenize='unicode61'
+ );
`)
+ // Triggers de sincronización FTS (solo si no existen).
+ db.exec(`
+ CREATE TRIGGER IF NOT EXISTS posts_ai AFTER INSERT ON posts BEGIN
+ INSERT INTO posts_fts(rowid, title, title_jpn, artist, parody, tags)
+ VALUES (new.id, new.title, new.title_jpn, new.artist, new.parody, new.tags);
+ END;
+ CREATE TRIGGER IF NOT EXISTS posts_ad AFTER DELETE ON posts BEGIN
+ INSERT INTO posts_fts(posts_fts, rowid, title, title_jpn, artist, parody, tags)
+ VALUES ('delete', old.id, old.title, old.title_jpn, old.artist, old.parody, old.tags);
+ END;
+ CREATE TRIGGER IF NOT EXISTS posts_au AFTER UPDATE ON posts BEGIN
+ INSERT INTO posts_fts(posts_fts, rowid, title, title_jpn, artist, parody, tags)
+ VALUES ('delete', old.id, old.title, old.title_jpn, old.artist, old.parody, old.tags);
+ INSERT INTO posts_fts(rowid, title, title_jpn, artist, parody, tags)
+ VALUES (new.id, new.title, new.title_jpn, new.artist, new.parody, new.tags);
+ END;
+ `)
+
+ // Reindexar FTS si la tabla externa quedó desincronizada (posts sin indexar).
+ try {
+ const ftsCount = db.prepare("SELECT COUNT(*) as c FROM posts_fts").get() as { c: number }
+ const postCount = db.prepare("SELECT COUNT(*) as c FROM posts").get() as { c: number }
+ if (ftsCount.c < postCount.c) {
+ db.exec("INSERT INTO posts_fts(posts_fts) VALUES('rebuild')")
+ }
+ } catch {}
+
try {
db.exec("ALTER TABLE posts ADD COLUMN url TEXT")
} catch {}
@@ -214,22 +250,32 @@ export function getPostCount(): number {
return row.count
}
-// Búsqueda pública por título/título_jpn/artista/tags (LIKE sobre JSON).
+// Búsqueda pública por título/título_jpn/artista/tags (FTS5 full-text).
+// Devuelve los posts MATCH (por relevancia), filtrando publicados.
export function searchPosts(query: string, publishedOnly = true): Post[] {
const d = getDb()
- const q = `%${query}%`
- const where = publishedOnly ? "published = 1 AND" : ""
+ const q = query.trim()
+ if (!q) return []
+
+ // Escapar comillas para FTS5 y construir una búsqueda por prefijo en cada token.
+ const escaped = q.replace(/"/g, "").trim()
+ if (!escaped) return []
+
+ const ftsQuery = escaped
+ .split(/\s+/)
+ .filter(Boolean)
+ .map((tok) => `"${tok}"*`)
+ .join(" AND ")
+
+ const where = publishedOnly ? "AND p.published = 1" : ""
const rows = d
.prepare(
- `SELECT * FROM posts WHERE ${where} (
- title LIKE @q OR
- title_jpn LIKE @q OR
- artist LIKE @q OR
- parody LIKE @q OR
- tags LIKE @q
- ) ORDER BY published_at DESC LIMIT 100`,
+ `SELECT p.* FROM posts p
+ JOIN posts_fts f ON f.rowid = p.id
+ WHERE posts_fts MATCH @q ${where}
+ ORDER BY bm25(posts_fts) LIMIT 100`,
)
- .all({ q }) as Record[]
+ .all({ q: ftsQuery }) as Record[]
return rows.map(rowToPost)
}
diff --git a/src/middleware.ts b/src/middleware.ts
index 2617779..b0ac594 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -9,6 +9,8 @@ const publicExact = new Set([
"/api/health",
"/api/proxy/health",
"/setup",
+ "/rss.xml",
+ "/sitemap.xml",
])
// Prefijos públicos: /api/cover (imágenes de portada para el sitio público),