From 7f7ee254d8f14b45858a12ff59b8554910228518 Mon Sep 17 00:00:00 2001 From: virtus Date: Sat, 14 Mar 2026 02:15:09 +0700 Subject: [PATCH] Refactor code structure for improved readability and maintainability --- .github/workflows/docker-publish.yml | 43 +++++++++ app/api/mod/epub/route.ts | 84 ++++++++++++++++- app/api/user/bookmarks/route.ts | 49 +++++----- app/mod/truyen/novel-client.tsx | 130 +++++++++++++++------------ app/page.tsx | 7 +- components/home-hot-carousel.tsx | 6 +- next-env.d.ts | 2 +- pnpm-workspace.yaml | 5 ++ tsconfig.tsbuildinfo | 2 +- 9 files changed, 241 insertions(+), 87 deletions(-) create mode 100644 .github/workflows/docker-publish.yml create mode 100644 pnpm-workspace.yaml diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..fcc8aaa --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -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 diff --git a/app/api/mod/epub/route.ts b/app/api/mod/epub/route.ts index adbae0f..2773fdd 100644 --- a/app/api/mod/epub/route.ts +++ b/app/api/mod/epub/route.ts @@ -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 = { 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 { 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 }) } diff --git a/app/api/user/bookmarks/route.ts b/app/api/user/bookmarks/route.ts index db8ea5b..b809670 100644 --- a/app/api/user/bookmarks/route.ts +++ b/app/api/user/bookmarks/route.ts @@ -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({ - 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 prisma.novel.update({ + where: { id: novelId }, + data: { views: { increment: 1 } } + }) + await upsertDailyNovelView(novelId, day) } return NextResponse.json({ status: "updated", bookmark }) diff --git a/app/mod/truyen/novel-client.tsx b/app/mod/truyen/novel-client.tsx index 16623f7..3e3b843 100644 --- a/app/mod/truyen/novel-client.tsx +++ b/app/mod/truyen/novel-client.tsx @@ -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,79 +951,89 @@ export function NovelClient() { try { for (const file of pendingEpubFiles) { const fileKey = buildEpubFileKey(file) - const formData = new FormData() - formData.append("file", file) - formData.append("seriesMode", epubSeriesMode) - if (epubSeriesMode === "existing") formData.append("seriesId", epubSeriesId) - if (epubSeriesMode === "new") formData.append("seriesName", epubSeriesName.trim()) - formData.append("splitMode", "toc") + try { + const formData = new FormData() + formData.append("file", file) + formData.append("seriesMode", epubSeriesMode) + if (epubSeriesMode === "existing") formData.append("seriesId", epubSeriesId) + if (epubSeriesMode === "new") formData.append("seriesName", epubSeriesName.trim()) + formData.append("splitMode", "toc") - setBulkProgressItem(fileKey, { - status: "uploading", - progress: 1, - message: "Đang upload...", - }) + setBulkProgressItem(fileKey, { + status: "uploading", + progress: 1, + message: "Đang upload...", + }) - let upload = await uploadEpubRequest(formData, (progress) => { - setBulkProgressItem(fileKey, { progress, status: "uploading" }) - }) + let upload = await uploadEpubRequest(formData, (progress) => { + setBulkProgressItem(fileKey, { progress, status: "uploading" }) + }) - if (upload.status === 409 && upload.data?.code === "DUPLICATE_TITLE") { - const duplicateTitle = upload.data.existingNovel?.title || file.name + if (upload.status === 409 && upload.data?.code === "DUPLICATE_TITLE") { + const duplicateTitle = upload.data.existingNovel?.title || file.name - if (upload.data.canReplace === false) { + if (upload.data.canReplace === false) { + failed += 1 + setBulkProgressItem(fileKey, { + status: "failed", + progress: 100, + message: upload.data.error || `Trùng tên ${duplicateTitle} nhưng không đủ quyền replace`, + }) + continue + } + + let shouldReplace = false + if (bulkDuplicateHandling === "replace-all") { + shouldReplace = true + } else if (bulkDuplicateHandling === "skip-all") { + shouldReplace = false + } else { + shouldReplace = window.confirm(`File ${file.name} trùng với truyện "${duplicateTitle}". Bạn có muốn replace không?`) + } + + if (!shouldReplace) { + skipped += 1 + setBulkProgressItem(fileKey, { + status: "skipped", + progress: 100, + message: bulkDuplicateHandling === "skip-all" ? "Bỏ qua theo cấu hình" : "Đã bỏ qua do trùng tên", + }) + continue + } + + const retryFormData = cloneFormData(formData) + retryFormData.set("replaceExisting", "true") + upload = await uploadEpubRequest(retryFormData, (progress) => { + setBulkProgressItem(fileKey, { progress, status: "uploading" }) + }) + } + + if (upload.ok) { + success += 1 + if (upload.data?.replaced) { + replaced += 1 + } + setBulkProgressItem(fileKey, { + status: "success", + progress: 100, + message: upload.data?.replaced ? "Đã replace thành công" : "Upload thành công", + }) + } else { failed += 1 setBulkProgressItem(fileKey, { status: "failed", progress: 100, - message: upload.data.error || `Trùng tên ${duplicateTitle} nhưng không đủ quyền replace`, + message: upload.data?.error || "Upload thất bại", }) - continue } - - let shouldReplace = false - if (bulkDuplicateHandling === "replace-all") { - shouldReplace = true - } else if (bulkDuplicateHandling === "skip-all") { - shouldReplace = false - } else { - shouldReplace = window.confirm(`File ${file.name} trùng với truyện "${duplicateTitle}". Bạn có muốn replace không?`) - } - - if (!shouldReplace) { - skipped += 1 - setBulkProgressItem(fileKey, { - status: "skipped", - progress: 100, - message: bulkDuplicateHandling === "skip-all" ? "Bỏ qua theo cấu hình" : "Đã bỏ qua do trùng tên", - }) - continue - } - - const retryFormData = cloneFormData(formData) - retryFormData.set("replaceExisting", "true") - upload = await uploadEpubRequest(retryFormData, (progress) => { - setBulkProgressItem(fileKey, { progress, status: "uploading" }) - }) - } - - if (upload.ok) { - success += 1 - if (upload.data?.replaced) { - replaced += 1 - } - setBulkProgressItem(fileKey, { - status: "success", - progress: 100, - message: upload.data?.replaced ? "Đã replace thành công" : "Upload thành công", - }) - } else { + } catch (err: any) { failed += 1 setBulkProgressItem(fileKey, { status: "failed", progress: 100, - message: upload.data?.error || "Upload thất bại", + message: err?.message || "Upload thất bại do lỗi kết nối", }) + continue } } diff --git a/app/page.tsx b/app/page.tsx index cfada09..68fac14 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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[] diff --git a/components/home-hot-carousel.tsx b/components/home-hot-carousel.tsx index c3f1737..3571c39 100644 --- a/components/home-hot-carousel.tsx +++ b/components/home-hot-carousel.tsx @@ -67,8 +67,8 @@ export function HomeHotCarousel({ items }: { items: HotCarouselItem[] }) {
- {items.map((item) => ( -
+ {items.map((item, index) => ( +
@@ -137,7 +137,7 @@ export function HomeHotCarousel({ items }: { items: HotCarouselItem[] }) {
{items.map((item, index) => (