Refactor ImportClient component: add pagination, tab switching, and auto-review functionality; remove unused models and scripts
Build and Push Reader Image / docker (push) Failing after 10s
Build and Push Reader Image / docker (push) Failing after 10s
This commit is contained in:
@@ -14,11 +14,17 @@ export function ImportClient() {
|
||||
const [assetQuery, setAssetQuery] = useState("")
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [limit] = useState(50)
|
||||
|
||||
const [selectedJobId, setSelectedJobId] = useState("")
|
||||
const [mapNovelId, setMapNovelId] = useState("")
|
||||
const [tab, setTab] = useState<"unconverted" | "converted">("unconverted")
|
||||
|
||||
const unconvertedAssets = useMemo(() => assets.filter((a) => a.status !== "completed"), [assets])
|
||||
const visibleAssets = useMemo(
|
||||
() => tab === "unconverted" ? assets.filter((a) => a.status !== "completed") : assets,
|
||||
[assets, tab],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedQuery(assetQuery.trim()), 250)
|
||||
@@ -28,7 +34,9 @@ export function ImportClient() {
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const aUrl = `/api/mod-import/assets?unconvertedOnly=true&limit=200${debouncedQuery ? `&q=${encodeURIComponent(debouncedQuery)}` : ""}`
|
||||
const aUrl = tab === "unconverted"
|
||||
? `/api/mod-import/assets?unconvertedOnly=true&limit=${limit}&offset=${offset}${debouncedQuery ? `&q=${encodeURIComponent(debouncedQuery)}` : ""}`
|
||||
: `/api/mod-import/assets?status=completed&limit=${limit}&offset=${offset}${debouncedQuery ? `&q=${encodeURIComponent(debouncedQuery)}` : ""}`
|
||||
const [aRes, jRes] = await Promise.all([fetch(aUrl), fetch("/api/mod-import/review-required")])
|
||||
const aData = await aRes.json().catch(() => [])
|
||||
const jData = await jRes.json().catch(() => [])
|
||||
@@ -45,13 +53,13 @@ export function ImportClient() {
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [debouncedQuery])
|
||||
}, [debouncedQuery, offset, tab])
|
||||
|
||||
const selectItem = (assetId: string, index: number, shiftKey: boolean) => {
|
||||
if (shiftKey && lastIndex !== null) {
|
||||
const start = Math.min(lastIndex, index)
|
||||
const end = Math.max(lastIndex, index)
|
||||
const range = unconvertedAssets.slice(start, end + 1).map((x) => x.id)
|
||||
const range = visibleAssets.slice(start, end + 1).map((x) => x.id)
|
||||
setSelectedAssetIds((prev) => Array.from(new Set([...prev, ...range])))
|
||||
return
|
||||
}
|
||||
@@ -86,6 +94,14 @@ export function ImportClient() {
|
||||
refresh()
|
||||
}
|
||||
|
||||
const autoReview = async () => {
|
||||
const res = await fetch("/api/mod-import/assets/auto-review?limit=5000", { method: "POST" })
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) return toast.error((data as any)?.detail || "Auto-review thất bại")
|
||||
toast.success(`Đã phân loại ${data.processed}: approved ${data.approved}, review ${data.reviewRequired}`)
|
||||
refresh()
|
||||
}
|
||||
|
||||
const mapMismatch = async () => {
|
||||
if (!selectedJobId || !mapNovelId) return toast.error("Chọn mismatch job và nhập novelId")
|
||||
const res = await fetch(`/api/mod-import/jobs/${selectedJobId}/apply-mapping`, {
|
||||
@@ -99,6 +115,25 @@ export function ImportClient() {
|
||||
refresh()
|
||||
}
|
||||
|
||||
const markConverted = async (assetId: string) => {
|
||||
if (!confirm("Đánh dấu EPUB này đã convert (ẩn khỏi danh sách chưa convert)?")) return
|
||||
const res = await fetch(`/api/mod-import/assets/${assetId}/mark-converted`, { method: "POST" })
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) return toast.error((data as any)?.detail || "Mark converted thất bại")
|
||||
toast.success("Đã đánh dấu converted")
|
||||
setSelectedAssetIds((prev) => prev.filter((x) => x !== assetId))
|
||||
refresh()
|
||||
}
|
||||
|
||||
const unmarkConverted = async (assetId: string) => {
|
||||
if (!confirm("Bỏ trạng thái converted để convert lại?")) return
|
||||
const res = await fetch(`/api/mod-import/assets/${assetId}/unmark-converted`, { method: "POST" })
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) return toast.error((data as any)?.detail || "Unmark thất bại")
|
||||
toast.success("Đã unmark converted")
|
||||
refresh()
|
||||
}
|
||||
|
||||
const deleteMismatchJob = async (jobId: string) => {
|
||||
if (!confirm("Xoá mismatch job này và xoá luôn thư mục content trên NAS?")) return
|
||||
const res = await fetch(`/api/mod-import/jobs/${jobId}?removeContent=true`, { method: "DELETE" })
|
||||
@@ -117,25 +152,41 @@ export function ImportClient() {
|
||||
<h3 className="font-semibold">EPUB nguồn (chưa convert)</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">Click để chọn, Shift + click để chọn một khoảng, sau đó bấm Convert.</p>
|
||||
</div>
|
||||
<button onClick={refresh} className="rounded border px-3 py-2 text-sm">{loading ? "Đang tải..." : "Refresh"}</button>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={autoReview} className="rounded border px-3 py-2 text-sm">Auto Review</button>
|
||||
<button onClick={refresh} className="rounded border px-3 py-2 text-sm">{loading ? "Đang tải..." : "Refresh"}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-3 flex gap-2">
|
||||
<button onClick={() => { setTab("unconverted"); setOffset(0) }} className={`rounded px-3 py-1 text-sm ${tab === "unconverted" ? "bg-primary text-primary-foreground" : "border"}`}>Chưa convert</button>
|
||||
<button onClick={() => { setTab("converted"); setOffset(0) }} className={`rounded px-3 py-1 text-sm ${tab === "converted" ? "bg-primary text-primary-foreground" : "border"}`}>Đã convert</button>
|
||||
</div>
|
||||
<input className="border rounded px-3 py-2 text-sm w-full mb-3" placeholder="Tìm theo tên file/thư mục..." value={assetQuery} onChange={(e) => setAssetQuery(e.target.value)} />
|
||||
<div className="space-y-2">
|
||||
{unconvertedAssets.map((a, idx) => (
|
||||
<button
|
||||
key={a.id}
|
||||
onClick={(e) => selectItem(a.id, idx, e.shiftKey)}
|
||||
className={`w-full rounded-xl border p-3 text-left transition ${selectedAssetIds.includes(a.id) ? "border-primary bg-primary/5" : "hover:bg-muted/40"}`}
|
||||
>
|
||||
<div className="text-sm font-medium">{a.path}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">status: {a.status}</div>
|
||||
</button>
|
||||
{visibleAssets.map((a, idx) => (
|
||||
<div key={a.id} className={`rounded-xl border p-3 transition ${selectedAssetIds.includes(a.id) ? "border-primary bg-primary/5" : "hover:bg-muted/40"}`}>
|
||||
<button
|
||||
onClick={(e) => selectItem(a.id, idx, e.shiftKey)}
|
||||
className="w-full text-left"
|
||||
>
|
||||
<div className="text-sm font-medium">{a.path}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">status: {a.status}</div>
|
||||
</button>
|
||||
<div className="mt-2 flex justify-end gap-2">
|
||||
{tab === "unconverted" ? <button onClick={() => markConverted(a.id)} className="rounded border px-2 py-1 text-xs">Mark Converted</button> : null}
|
||||
{tab === "converted" ? <button onClick={() => unmarkConverted(a.id)} className="rounded border px-2 py-1 text-xs">Unmark</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{unconvertedAssets.length === 0 && <div className="text-sm text-muted-foreground">Không có EPUB phù hợp</div>}
|
||||
{visibleAssets.length === 0 && <div className="text-sm text-muted-foreground">Không có EPUB phù hợp</div>}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
<div className="text-xs text-muted-foreground">Đã chọn {selectedAssetIds.length} file</div>
|
||||
<button disabled={selectedAssetIds.length === 0} onClick={convertSelected} className="rounded bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-50">Convert</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="rounded border px-2 py-1 text-xs disabled:opacity-50" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - limit))}>Prev</button>
|
||||
<button className="rounded border px-2 py-1 text-xs" onClick={() => setOffset(offset + limit)}>Next</button>
|
||||
<button disabled={selectedAssetIds.length === 0} onClick={convertSelected} className="rounded bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-50">Convert</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
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 },
|
||||
createdAt: { type: Date, default: Date.now },
|
||||
})
|
||||
|
||||
ChapterSchema.index({ novelId: 1, number: 1 }, { unique: true })
|
||||
ChapterSchema.index({ createdAt: -1, novelId: 1 })
|
||||
|
||||
export const Chapter = mongoose.models.Chapter || mongoose.model<IChapter>("Chapter", ChapterSchema)
|
||||
@@ -1,25 +0,0 @@
|
||||
import mongoose, { Schema, Document } from "mongoose"
|
||||
|
||||
export interface IEditorRecommendation extends Document {
|
||||
novelId: string
|
||||
editorId: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
const EditorRecommendationSchema: Schema = new Schema(
|
||||
{
|
||||
novelId: { type: String, required: true, index: true },
|
||||
editorId: { type: String, required: true, index: true },
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
}
|
||||
)
|
||||
|
||||
EditorRecommendationSchema.index({ novelId: 1, editorId: 1 }, { unique: true })
|
||||
EditorRecommendationSchema.index({ createdAt: -1 })
|
||||
|
||||
export const EditorRecommendation =
|
||||
mongoose.models.EditorRecommendation ||
|
||||
mongoose.model<IEditorRecommendation>("EditorRecommendation", EditorRecommendationSchema)
|
||||
@@ -1,25 +0,0 @@
|
||||
import mongoose, { Document, Schema } from "mongoose"
|
||||
|
||||
export interface IUserRecommendation extends Document {
|
||||
userId: string
|
||||
novelId: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
const UserRecommendationSchema: Schema = new Schema(
|
||||
{
|
||||
userId: { type: String, required: true, index: true },
|
||||
novelId: { type: String, required: true, index: true },
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
}
|
||||
)
|
||||
|
||||
UserRecommendationSchema.index({ userId: 1, novelId: 1 }, { unique: true })
|
||||
UserRecommendationSchema.index({ createdAt: -1 })
|
||||
|
||||
export const UserRecommendation =
|
||||
mongoose.models.UserRecommendation ||
|
||||
mongoose.model<IUserRecommendation>("UserRecommendation", UserRecommendationSchema)
|
||||
@@ -1,39 +0,0 @@
|
||||
import mongoose from "mongoose"
|
||||
|
||||
let cached = (global as any).mongoose
|
||||
|
||||
if (!cached) {
|
||||
cached = (global as any).mongoose = { conn: null, promise: null }
|
||||
}
|
||||
|
||||
async function connectToMongoDB() {
|
||||
const mongodbUri = process.env.MONGODB_URI
|
||||
if (!mongodbUri) {
|
||||
throw new Error("Please define the MONGODB_URI environment variable")
|
||||
}
|
||||
|
||||
if (cached.conn) {
|
||||
return cached.conn
|
||||
}
|
||||
|
||||
if (!cached.promise) {
|
||||
const opts = {
|
||||
bufferCommands: false,
|
||||
}
|
||||
|
||||
cached.promise = mongoose.connect(mongodbUri, opts).then((mongoose) => {
|
||||
return mongoose
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
cached.conn = await cached.promise
|
||||
} catch (e) {
|
||||
cached.promise = null
|
||||
throw e
|
||||
}
|
||||
|
||||
return cached.conn
|
||||
}
|
||||
|
||||
export default connectToMongoDB
|
||||
@@ -52,7 +52,6 @@
|
||||
"input-otp": "1.4.2",
|
||||
"lucide-react": "^0.564.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"mongoose": "^9.2.4",
|
||||
"next": "16.1.6",
|
||||
"next-auth": "^4.24.13",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
const { PrismaClient } = require('@prisma/client')
|
||||
const mongoose = require('mongoose')
|
||||
require('dotenv').config({ path: '.env.local' })
|
||||
require('dotenv').config()
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
console.log('Connecting to MongoDB...')
|
||||
// Connect to MongoDB using MONGODB_URI
|
||||
const mongoUri = process.env.MONGODB_URI
|
||||
if (!mongoUri) {
|
||||
throw new Error('MONGODB_URI is not defined in env')
|
||||
}
|
||||
await mongoose.connect(mongoUri)
|
||||
|
||||
// Wipe MongoDB Chapters
|
||||
console.log('Wiping chapters from MongoDB...')
|
||||
try {
|
||||
const chapterSchema = new mongoose.Schema({}, { strict: false })
|
||||
const Chapter = mongoose.models.Chapter || mongoose.model('Chapter', chapterSchema, 'chapters')
|
||||
const res = await Chapter.deleteMany({})
|
||||
console.log(`Deleted ${res.deletedCount} chapters.`)
|
||||
} catch (e) {
|
||||
console.error('Error wiping mongo chapters', e)
|
||||
}
|
||||
|
||||
// Wipe PostgreSQL Content
|
||||
console.log('Wiping Novels, Genres, Comments, Bookmarks from PostgreSQL...')
|
||||
try {
|
||||
// Delete in order to respect foreign keys if Cascade isn't perfect, but Cascade is set on most.
|
||||
await prisma.comment.deleteMany({})
|
||||
console.log('Deleted all comments.')
|
||||
|
||||
await prisma.bookmark.deleteMany({})
|
||||
console.log('Deleted all bookmarks.')
|
||||
|
||||
await prisma.novelGenre.deleteMany({})
|
||||
console.log('Deleted all novel_genres.')
|
||||
|
||||
await prisma.genre.deleteMany({})
|
||||
console.log('Deleted all genres.')
|
||||
|
||||
await prisma.novel.deleteMany({})
|
||||
console.log('Deleted all novels.')
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error wiping postgres', error)
|
||||
}
|
||||
|
||||
console.log('Cleanup complete.')
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
await mongoose.disconnect()
|
||||
process.exit(0)
|
||||
})
|
||||
Reference in New Issue
Block a user