Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-03-14 02:15:09 +07:00
parent ac9cecdcdb
commit 7f7ee254d8
9 changed files with 241 additions and 87 deletions
+43
View File
@@ -0,0 +1,43 @@
name: Build and Push Docker Image
on:
push:
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: |
fevirtus/reader:latest
ghcr.io/fevirtus/reader:latest
cache-from: type=gha
cache-to: type=gha,mode=max
+83 -1
View File
@@ -11,6 +11,8 @@ import { convert } from "html-to-text"
import { slugify } from "@/lib/utils"
import { deleteR2ObjectByUrl, uploadBufferToR2 } from "@/lib/r2"
export const maxDuration = 900
type SplitMode = "toc" | "regex"
type SeriesMode = "none" | "existing" | "new"
@@ -38,6 +40,81 @@ interface EpubCoverAsset {
sourceId: string | null
}
type EpubCtor = new (epubfile: string, imagewebroot: string, chapterwebroot: string) => any
function sanitizeEpubNavMapBranch(branch: any): any {
if (Array.isArray(branch)) {
branch.forEach((item) => sanitizeEpubNavMapBranch(item))
return branch
}
if (!branch || typeof branch !== "object") {
return branch
}
if ("navLabel" in branch) {
const navLabel = (branch as any).navLabel
if (typeof navLabel === "string") {
;(branch as any).navLabel = navLabel
} else if (navLabel && typeof navLabel === "object") {
if (typeof navLabel.text === "string") {
;(branch as any).navLabel = navLabel.text
} else if (navLabel.text !== undefined && navLabel.text !== null) {
;(branch as any).navLabel = String(navLabel.text)
} else {
;(branch as any).navLabel = ""
}
} else if (navLabel === null || navLabel === undefined) {
;(branch as any).navLabel = ""
} else {
;(branch as any).navLabel = String(navLabel)
}
}
if ((branch as any).navPoint) {
sanitizeEpubNavMapBranch((branch as any).navPoint)
}
return branch
}
function getPatchedEpubCtor(): EpubCtor {
const loaded = require("epub2")
const EPub = (loaded?.EPub || loaded) as EpubCtor
const proto = (EPub as any)?.prototype
if (proto && !proto.__readerSafeNavLabelPatchApplied && typeof proto.walkNavMap === "function") {
const originalWalkNavMap = proto.walkNavMap
proto.walkNavMap = function patchedWalkNavMap(branch: any, ...args: any[]) {
const safeBranch = sanitizeEpubNavMapBranch(branch)
return originalWalkNavMap.call(this, safeBranch, ...args)
}
proto.__readerSafeNavLabelPatchApplied = true
}
return EPub
}
function isRequestAbortedError(error: unknown): boolean {
if (!error) return false
const candidate = error as { code?: unknown; message?: unknown; name?: unknown }
const code = typeof candidate.code === "string" ? candidate.code.toUpperCase() : ""
const message = typeof candidate.message === "string" ? candidate.message.toLowerCase() : ""
const name = typeof candidate.name === "string" ? candidate.name.toLowerCase() : ""
return (
code === "ECONNRESET" ||
code === "ABORT_ERR" ||
message.includes("aborted") ||
message.includes("connection reset") ||
name.includes("abort")
)
}
const CHAPTER_REGEX_PRESETS: Record<string, string> = {
vi_chuong: "^(?:Chương|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
en_chapter: "^(?:Chapter|Ch\\.)\\s*\\d+(?:\\.\\d+)?[^\\n]*$",
@@ -838,7 +915,7 @@ async function saveCoverBufferToR2(cover: EpubCoverAsset): Promise<string | null
async function parseEpubSections(tempFilePath: string): Promise<{ metadata: any; sections: EpubSection[]; cover: EpubCoverAsset }> {
return new Promise((resolve, reject) => {
const EPub = require("epub2").EPub || require("epub2")
const EPub = getPatchedEpubCtor()
const epub = new EPub(tempFilePath, "", "")
epub.on("error", (err: any) => reject(err))
@@ -1168,6 +1245,11 @@ export async function POST(req: Request) {
replaced,
}, { status: responseStatus })
} catch (error: any) {
if (isRequestAbortedError(error)) {
console.warn("EPUB upload aborted by client or network interruption")
return NextResponse.json({ error: "Kết nối upload bị ngắt trong lúc xử lý" }, { status: 499 })
}
console.error("EPUB upload error:", error)
return NextResponse.json({ error: "Lỗi xử lý file EPUB", details: error.message }, { status: 500 })
}
+25 -20
View File
@@ -7,6 +7,28 @@ function toUTCDateOnly(value: Date): Date {
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()))
}
async function upsertDailyNovelView(novelId: string, day: Date) {
const delegate = (prisma as any).novelViewDaily
if (!delegate || typeof delegate.upsert !== "function") return
await delegate.upsert({
where: {
novelId_day: {
novelId,
day,
},
},
update: {
views: { increment: 1 },
},
create: {
novelId,
day,
views: 1,
},
})
}
// Lấy danh sách bookmark
export async function GET(req: Request) {
try {
@@ -140,28 +162,11 @@ export async function POST(req: Request) {
if (shouldIncrementNovelView) {
const day = toUTCDateOnly(new Date())
await prisma.$transaction([
prisma.novel.update({
await prisma.novel.update({
where: { id: novelId },
data: { views: { increment: 1 } }
}),
prisma.novelViewDaily.upsert({
where: {
novelId_day: {
novelId,
day,
},
},
update: {
views: { increment: 1 },
},
create: {
novelId,
day,
views: 1,
},
}),
])
})
await upsertDailyNovelView(novelId, day)
}
return NextResponse.json({ status: "updated", bookmark })
+14
View File
@@ -224,6 +224,8 @@ export function NovelClient() {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open("POST", "/api/mod/epub")
// Large EPUB imports can take several minutes when parsing + writing many chapters.
xhr.timeout = 15 * 60 * 1000
xhr.upload.onprogress = (event) => {
if (!onProgress || !event.lengthComputable) return
@@ -232,6 +234,8 @@ export function NovelClient() {
}
xhr.onerror = () => reject(new Error("Không thể kết nối tới server"))
xhr.onabort = () => reject(new Error("Upload đã bị huỷ hoặc kết nối bị ngắt"))
xhr.ontimeout = () => reject(new Error("Upload quá lâu và đã hết thời gian chờ"))
xhr.onload = () => {
let data: EpubUploadResponseData = {}
@@ -947,6 +951,7 @@ export function NovelClient() {
try {
for (const file of pendingEpubFiles) {
const fileKey = buildEpubFileKey(file)
try {
const formData = new FormData()
formData.append("file", file)
formData.append("seriesMode", epubSeriesMode)
@@ -1021,6 +1026,15 @@ export function NovelClient() {
message: upload.data?.error || "Upload thất bại",
})
}
} catch (err: any) {
failed += 1
setBulkProgressItem(fileKey, {
status: "failed",
progress: 100,
message: err?.message || "Upload thất bại do lỗi kết nối",
})
continue
}
}
if (success > 0 && failed === 0 && skipped === 0) {
+6 -1
View File
@@ -348,8 +348,13 @@ export default async function HomePage() {
const hotWeekly = weeklyRanking.slice(0, 5).map((entry) => ({ ...entry, source: "week" as const }))
const hotMonthly = monthlyRanking.slice(0, 5).map((entry) => ({ ...entry, source: "month" as const }))
const hotAllTime = allTimeRanking.slice(0, 8).map((entry) => ({ ...entry, source: "all" as const }))
hotSlides = toHotCarouselItems([...hotWeekly, ...hotMonthly]).slice(0, 10)
hotSlides = fillUniqueRows(
toHotCarouselItems([...hotWeekly, ...hotMonthly]),
toHotCarouselItems(hotAllTime),
10,
)
const usedHotIds = new Set(hotSlides.map((item) => item.id))
const randomPool = randomPoolRaw as HomeNovel[]
+3 -3
View File
@@ -67,8 +67,8 @@ export function HomeHotCarousel({ items }: { items: HotCarouselItem[] }) {
<div className="space-y-4">
<div className="relative overflow-hidden rounded-2xl border border-border/70 bg-card/90 shadow-[0_20px_60px_-40px_rgba(251,146,60,0.45)]">
<div className="flex transition-transform duration-500" style={{ transform: `translateX(-${activeIndex * 100}%)` }}>
{items.map((item) => (
<div key={item.id} className="min-w-full">
{items.map((item, index) => (
<div key={`${item.id}-${item.hotSource}-${index}`} className="min-w-full">
<Link href={`/truyen/${item.slug}`} className="group block">
<div className="grid gap-0 md:grid-cols-[320px_1fr]">
<div className="relative h-[420px] overflow-hidden bg-muted/60 md:h-[460px]">
@@ -137,7 +137,7 @@ export function HomeHotCarousel({ items }: { items: HotCarouselItem[] }) {
<div className="flex items-center justify-center gap-2">
{items.map((item, index) => (
<button
key={item.id}
key={`${item.id}-${item.hotSource}-dot-${index}`}
type="button"
aria-label={`Slide ${index + 1}`}
onClick={() => setActiveIndex(index)}
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+5
View File
@@ -0,0 +1,5 @@
allowBuilds:
'@prisma/client': false
'@prisma/engines': false
prisma: false
sharp: false
+1 -1
View File
File diff suppressed because one or more lines are too long