feat: worst-scan fansub web — initial release
Posts engine (SQLite + auto-publisher via poller), public feed with clickable tags, reader, admin panel, submit/search/queue tools. BFF proxy to pipeline API. Clean dark design. Docker-ready.
This commit is contained in:
+196
@@ -0,0 +1,196 @@
|
||||
import Database from "better-sqlite3"
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || path.join(process.cwd(), "data", "worst-scan.db")
|
||||
|
||||
let db: Database.Database | null = null
|
||||
|
||||
function getDb(): Database.Database {
|
||||
if (!db) {
|
||||
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
|
||||
db = new Database(DB_PATH)
|
||||
db.pragma("journal_mode = WAL")
|
||||
db.pragma("foreign_keys = ON")
|
||||
migrate(db)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
function migrate(db: Database.Database) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
gid TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
title_jpn TEXT,
|
||||
artist TEXT,
|
||||
parody TEXT,
|
||||
tags TEXT,
|
||||
num_pages INTEGER DEFAULT 0,
|
||||
source TEXT,
|
||||
cover_url TEXT,
|
||||
summary TEXT,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
published INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
published_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_gid ON posts(gid);
|
||||
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);
|
||||
`)
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: number
|
||||
gid: string
|
||||
title: string
|
||||
title_jpn: string | null
|
||||
artist: string | null
|
||||
parody: string | null
|
||||
tags: string[]
|
||||
num_pages: number
|
||||
source: string | null
|
||||
cover_url: string | null
|
||||
summary: string | null
|
||||
slug: string
|
||||
published: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
published_at: string | null
|
||||
}
|
||||
|
||||
export interface PostInput {
|
||||
gid: string
|
||||
title: string
|
||||
title_jpn?: string
|
||||
artist?: string
|
||||
parody?: string
|
||||
tags?: string[]
|
||||
num_pages?: number
|
||||
source?: string
|
||||
cover_url?: string
|
||||
summary?: string
|
||||
slug: string
|
||||
published?: number
|
||||
}
|
||||
|
||||
function rowToPost(row: Record<string, unknown>): Post {
|
||||
return {
|
||||
...row,
|
||||
tags: typeof row.tags === "string" ? JSON.parse(row.tags as string) : [],
|
||||
} as unknown as Post
|
||||
}
|
||||
|
||||
export function getAllPosts(publishedOnly = false): Post[] {
|
||||
const d = getDb()
|
||||
const q = publishedOnly
|
||||
? "SELECT * FROM posts WHERE published = 1 ORDER BY published_at DESC"
|
||||
: "SELECT * FROM posts ORDER BY created_at DESC"
|
||||
return (d.prepare(q).all() as Record<string, unknown>[]).map(rowToPost)
|
||||
}
|
||||
|
||||
export function getPostById(id: number): Post | null {
|
||||
const d = getDb()
|
||||
const row = d.prepare("SELECT * FROM posts WHERE id = ?").get(id) as Record<string, unknown> | undefined
|
||||
return row ? rowToPost(row) : null
|
||||
}
|
||||
|
||||
export function getPostByGid(gid: string): Post | null {
|
||||
const d = getDb()
|
||||
const row = d.prepare("SELECT * FROM posts WHERE gid = ?").get(gid) as Record<string, unknown> | undefined
|
||||
return row ? rowToPost(row) : null
|
||||
}
|
||||
|
||||
export function getPostBySlug(slug: string): Post | null {
|
||||
const d = getDb()
|
||||
const row = d.prepare("SELECT * FROM posts WHERE slug = ?").get(slug) as Record<string, unknown> | undefined
|
||||
return row ? rowToPost(row) : null
|
||||
}
|
||||
|
||||
export function createPost(input: PostInput): Post {
|
||||
const d = getDb()
|
||||
const published = input.published ?? 1
|
||||
const publishedAt = published ? "datetime('now')" : null
|
||||
const stmt = d.prepare(`
|
||||
INSERT INTO posts (gid, title, title_jpn, artist, parody, tags, num_pages, source, cover_url, summary, slug, published, published_at)
|
||||
VALUES (@gid, @title, @title_jpn, @artist, @parody, @tags, @num_pages, @source, @cover_url, @summary, @slug, @published, ${publishedAt})
|
||||
`)
|
||||
const result = stmt.run({
|
||||
gid: input.gid,
|
||||
title: input.title,
|
||||
title_jpn: input.title_jpn || null,
|
||||
artist: input.artist || null,
|
||||
parody: input.parody || null,
|
||||
tags: JSON.stringify(input.tags || []),
|
||||
num_pages: input.num_pages || 0,
|
||||
source: input.source || null,
|
||||
cover_url: input.cover_url || null,
|
||||
summary: input.summary || null,
|
||||
slug: input.slug,
|
||||
published,
|
||||
})
|
||||
return getPostById(result.lastInsertRowid as number)!
|
||||
}
|
||||
|
||||
export function updatePost(id: number, updates: Partial<PostInput & { published: number }>): Post | null {
|
||||
const d = getDb()
|
||||
const fields: string[] = []
|
||||
const values: Record<string, unknown> = { id }
|
||||
|
||||
for (const [k, v] of Object.entries(updates)) {
|
||||
if (v !== undefined) {
|
||||
if (k === "tags") {
|
||||
fields.push("tags = @tags")
|
||||
values.tags = JSON.stringify(v)
|
||||
} else {
|
||||
fields.push(`${k} = @${k}`)
|
||||
values[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length === 0) return getPostById(id)
|
||||
|
||||
fields.push("updated_at = datetime('now')")
|
||||
d.prepare(`UPDATE posts SET ${fields.join(", ")} WHERE id = ?`).run(id)
|
||||
return getPostById(id)
|
||||
}
|
||||
|
||||
export function publishPost(id: number, publish: boolean): Post | null {
|
||||
const d = getDb()
|
||||
if (publish) {
|
||||
d.prepare("UPDATE posts SET published = 1, published_at = datetime('now'), updated_at = datetime('now') WHERE id = ?").run(id)
|
||||
} else {
|
||||
d.prepare("UPDATE posts SET published = 0, published_at = NULL, updated_at = datetime('now') WHERE id = ?").run(id)
|
||||
}
|
||||
return getPostById(id)
|
||||
}
|
||||
|
||||
export function deletePost(id: number): boolean {
|
||||
const d = getDb()
|
||||
const result = d.prepare("DELETE FROM posts WHERE id = ?").run(id)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function getPostsByTag(tag: string, publishedOnly = true): Post[] {
|
||||
const d = getDb()
|
||||
const q = publishedOnly
|
||||
? "SELECT * FROM posts WHERE published = 1 AND EXISTS (SELECT 1 FROM json_each(posts.tags) WHERE json_each.value = ?) ORDER BY published_at DESC"
|
||||
: "SELECT * FROM posts WHERE EXISTS (SELECT 1 FROM json_each(posts.tags) WHERE json_each.value = ?) ORDER BY created_at DESC"
|
||||
return (d.prepare(q).all(tag) as Record<string, unknown>[]).map(rowToPost)
|
||||
}
|
||||
|
||||
export function getPostCount(): number {
|
||||
const d = getDb()
|
||||
const row = d.prepare("SELECT COUNT(*) as count FROM posts").get() as { count: number }
|
||||
return row.count
|
||||
}
|
||||
|
||||
export function getGidsNotInPosts(): string[] {
|
||||
return []
|
||||
}
|
||||
Reference in New Issue
Block a user