Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-03-11 17:02:31 +07:00
parent 1139125460
commit 5686753ab7
42 changed files with 4659 additions and 309 deletions
+6
View File
@@ -3,6 +3,9 @@ import mongoose, { Schema, Document } from "mongoose"
export interface IChapter extends Document {
novelId: string // Trỏ tới ID trong PostgreSQL
number: number
volumeNumber?: number
volumeTitle?: string
volumeChapterNumber?: number
title: string
content: string
views: number
@@ -12,6 +15,9 @@ export interface IChapter extends Document {
const ChapterSchema: Schema = new Schema({
novelId: { type: String, required: true, index: true },
number: { type: Number, required: true },
volumeNumber: { type: Number, default: null },
volumeTitle: { type: String, default: null },
volumeChapterNumber: { type: Number, default: null },
title: { type: String, required: true },
content: { type: String, required: true },
views: { type: Number, default: 0 },
+17
View File
@@ -0,0 +1,17 @@
export function getNovelStatusBadgeClass(status: string): string {
const normalized = status.trim().toLowerCase()
if (normalized.includes("hoàn")) {
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
}
if (normalized.includes("tạm")) {
return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
}
if (normalized.includes("drop") || normalized.includes("hủy") || normalized.includes("cancel")) {
return "bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300"
}
return "bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300"
}
+140
View File
@@ -0,0 +1,140 @@
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"
import path from "path"
type UploadToR2Options = {
buffer: Buffer
contentType?: string | null
keyPrefix?: string
fileNameHint?: string
}
let cachedClient: S3Client | null = null
function requiredEnv(name: string): string {
const value = process.env[name]
if (!value) {
throw new Error(`Missing required environment variable: ${name}`)
}
return value
}
function optionalEnv(name: string): string | null {
const value = process.env[name]
return value ? value : null
}
function getR2Config() {
const accountId = requiredEnv("R2_ACCOUNT_ID")
const accessKeyId = requiredEnv("R2_ACCESS_KEY_ID")
const secretAccessKey = requiredEnv("R2_SECRET_ACCESS_KEY")
const bucket = requiredEnv("R2_BUCKET_NAME")
const publicBaseUrl = requiredEnv("R2_PUBLIC_BASE_URL")
return {
accountId,
accessKeyId,
secretAccessKey,
bucket,
publicBaseUrl: publicBaseUrl.replace(/\/+$/, ""),
}
}
function getR2Client(): S3Client {
if (cachedClient) return cachedClient
const { accountId, accessKeyId, secretAccessKey } = getR2Config()
cachedClient = new S3Client({
region: "auto",
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId,
secretAccessKey,
},
})
return cachedClient
}
function extensionFromMimeType(mimeType: string | null | undefined): string {
if (!mimeType) return ".jpg"
const normalized = mimeType.toLowerCase()
if (normalized.includes("png")) return ".png"
if (normalized.includes("webp")) return ".webp"
if (normalized.includes("gif")) return ".gif"
if (normalized.includes("avif")) return ".avif"
if (normalized.includes("jpeg") || normalized.includes("jpg")) return ".jpg"
return ".jpg"
}
function extensionFromHint(fileNameHint?: string): string {
if (!fileNameHint) return ""
const ext = path.extname(fileNameHint).toLowerCase()
if (!ext) return ""
if (!/^\.[a-z0-9]{1,8}$/.test(ext)) return ""
return ext
}
export async function uploadBufferToR2(options: UploadToR2Options): Promise<string> {
const client = getR2Client()
const { bucket, publicBaseUrl } = getR2Config()
const keyPrefix = (options.keyPrefix || "covers").replace(/^\/+|\/+$/g, "")
const ext = extensionFromHint(options.fileNameHint) || extensionFromMimeType(options.contentType)
const key = `${keyPrefix}/${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`
await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: options.buffer,
ContentType: options.contentType || "application/octet-stream",
})
)
return `${publicBaseUrl}/${key}`
}
export function getR2ObjectKeyFromUrl(url: string | null | undefined): string | null {
if (!url) return null
const publicBaseUrl = optionalEnv("R2_PUBLIC_BASE_URL")?.replace(/\/+$/, "")
if (!publicBaseUrl) return null
let baseUrl: URL
let fileUrl: URL
try {
baseUrl = new URL(publicBaseUrl)
fileUrl = new URL(url)
} catch {
return null
}
if (baseUrl.origin !== fileUrl.origin) return null
const basePath = baseUrl.pathname.replace(/\/+$/, "")
if (!fileUrl.pathname.startsWith(basePath)) return null
const relativePath = fileUrl.pathname.slice(basePath.length).replace(/^\/+/, "")
if (!relativePath) return null
return decodeURIComponent(relativePath)
}
export async function deleteR2ObjectByUrl(url: string | null | undefined): Promise<boolean> {
const key = getR2ObjectKeyFromUrl(url)
if (!key) return false
const client = getR2Client()
const { bucket } = getR2Config()
await client.send(
new DeleteObjectCommand({
Bucket: bucket,
Key: key,
})
)
return true
}
+8
View File
@@ -11,6 +11,11 @@ export interface Novel {
title: string
slug: string
authorName: string
series?: {
id: string
name: string
slug: string
} | null
coverColor: string
description: string
genres: string[]
@@ -28,6 +33,9 @@ export interface Chapter {
id: string
novelId: string
number: number
volumeNumber?: number
volumeTitle?: string
volumeChapterNumber?: number
title: string
content: string
views: number