Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-03-10 16:37:55 +07:00
parent 75ed8e233b
commit 8908395867
45 changed files with 2528 additions and 365 deletions
+73 -20
View File
@@ -7,14 +7,16 @@ import { Textarea } from "@/components/ui/textarea"
import { useAuth } from "@/lib/auth-context"
import type { Comment } from "@/lib/types"
import Link from "next/link"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
interface CommentSectionProps {
comments: Comment[]
chapterComments?: Comment[]
novelId: string
chapterId?: string
}
export function CommentSection({ comments: initialComments, novelId, chapterId }: CommentSectionProps) {
export function CommentSection({ comments: initialComments, chapterComments, novelId, chapterId }: CommentSectionProps) {
const { user } = useAuth()
const [comments, setComments] = useState(initialComments)
const [content, setContent] = useState("")
@@ -84,27 +86,78 @@ export function CommentSection({ comments: initialComments, novelId, chapterId }
</div>
)}
{/* Comments list */}
<div className="flex flex-col gap-4">
{comments.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Chưa bình luận nào. Hãy người đu tiên!</p>
) : (
comments.map((comment) => (
<div key={comment.id} className="flex gap-3">
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold text-background ${comment.avatarColor}`}>
{comment.username.charAt(0).toUpperCase()}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">{comment.username}</span>
<span className="text-xs text-muted-foreground">{comment.createdAt}</span>
{/* Comments list with Tabs for Novel Details Page */}
{chapterComments ? (
<Tabs defaultValue="novel" className="w-full">
<TabsList className="mb-4">
<TabsTrigger value="novel">Bình luận Truyện ({comments.length})</TabsTrigger>
<TabsTrigger value="chapter">Bình luận Chương ({chapterComments.length})</TabsTrigger>
</TabsList>
<TabsContent value="novel" className="flex flex-col gap-4 mt-0">
{comments.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Chưa bình luận nào cho truyện này. Hãy người đu tiên!</p>
) : (
comments.map((comment) => (
<div key={comment.id} className="flex gap-3">
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold text-background ${comment.avatarColor}`}>
{comment.username.charAt(0).toUpperCase()}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">{comment.username}</span>
<span className="text-xs text-muted-foreground">{comment.createdAt}</span>
</div>
<p className="mt-1 text-sm leading-relaxed text-foreground/90">{comment.content}</p>
</div>
</div>
))
)}
</TabsContent>
<TabsContent value="chapter" className="flex flex-col gap-4 mt-0">
{chapterComments.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Chưa bình luận nào trên các chương.</p>
) : (
chapterComments.map((comment) => (
<div key={comment.id} className="flex gap-3">
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold text-background ${comment.avatarColor}`}>
{comment.username.charAt(0).toUpperCase()}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">{comment.username}</span>
<span className="text-xs text-muted-foreground">{comment.createdAt}</span>
</div>
<p className="mt-1 text-sm leading-relaxed text-foreground/90">{comment.content}</p>
</div>
</div>
))
)}
</TabsContent>
</Tabs>
) : (
<div className="flex flex-col gap-4">
{comments.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Chưa bình luận nào. Hãy người đu tiên!</p>
) : (
comments.map((comment) => (
<div key={comment.id} className="flex gap-3">
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold text-background ${comment.avatarColor}`}>
{comment.username.charAt(0).toUpperCase()}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">{comment.username}</span>
<span className="text-xs text-muted-foreground">{comment.createdAt}</span>
</div>
<p className="mt-1 text-sm leading-relaxed text-foreground/90">{comment.content}</p>
</div>
<p className="mt-1 text-sm leading-relaxed text-foreground/90">{comment.content}</p>
</div>
</div>
))
)}
</div>
))
)}
</div>
)}
</div>
)
}
+3 -3
View File
@@ -8,15 +8,15 @@ export function Footer() {
<div className="flex flex-col items-center gap-6 md:flex-row md:justify-between">
<div className="flex items-center gap-2">
<BookOpen className="h-5 w-5 text-primary" />
<span className="text-lg font-bold text-foreground">TruyenChu</span>
<span className="text-lg font-bold text-foreground">Virtus's Reader</span>
</div>
<nav className="flex flex-wrap items-center justify-center gap-4 text-sm text-muted-foreground">
<Link href="/" className="transition-colors hover:text-foreground">Trang Chủ</Link>
<Link href="/the-loai" className="transition-colors hover:text-foreground">Thể Loại</Link>
<Link href="/tim-kiem" className="transition-colors hover:text-foreground">Tìm Kiếm</Link>
</nav>
<p className="text-xs text-muted-foreground">
TruyenChu - Đc truyện chữ online
<p className="text-xs text-muted-foreground text-center">
Virtus's Reader - Đc truyện chữ online
</p>
</div>
</div>
+34 -10
View File
@@ -3,7 +3,7 @@
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import { useState } from "react"
import { BookOpen, Menu, X, Search, User, LogOut, BookMarked } from "lucide-react"
import { BookOpen, Menu, X, Search, User as UserIcon, LogOut, BookMarked, Shield } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet"
@@ -36,9 +36,9 @@ export function Header() {
<header className="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto flex h-14 max-w-6xl items-center gap-4 px-4">
{/* Logo */}
<Link href="/" className="flex shrink-0 items-center gap-2">
<Link href="/" className="flex shrink-0 items-center gap-2 pr-2">
<BookOpen className="h-5 w-5 text-primary" />
<span className="text-lg font-bold text-foreground">TruyenChu</span>
<span className="text-lg font-bold text-foreground">Virtus's Reader</span>
</Link>
{/* Desktop Nav */}
@@ -95,6 +95,17 @@ export function Header() {
<p className="text-xs text-muted-foreground">{user.email}</p>
</div>
<DropdownMenuSeparator />
{(user.role === "MOD" || user.role === "ADMIN") && (
<>
<DropdownMenuItem asChild>
<Link href="/mod" className="flex items-center gap-2 text-primary font-medium">
<Shield className="h-4 w-4" />
Trang Quản Trị
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem asChild>
<Link href="/tu-sach" className="flex items-center gap-2">
<BookMarked className="h-4 w-4" />
@@ -155,13 +166,26 @@ export function Header() {
</Link>
))}
{user && (
<Link
href="/tu-sach"
onClick={() => setOpen(false)}
className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-secondary hover:text-foreground"
>
Tủ Sách
</Link>
<>
{(user.role === "MOD" || user.role === "ADMIN") && (
<Link
href="/mod"
onClick={() => setOpen(false)}
className="rounded-md px-3 py-2 text-sm font-medium text-primary hover:bg-secondary flex items-center gap-2"
>
<Shield className="h-4 w-4" />
Trang Quản Trị
</Link>
)}
<Link
href="/tu-sach"
onClick={() => setOpen(false)}
className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-secondary hover:text-foreground flex items-center gap-2"
>
<BookMarked className="h-4 w-4" />
Tủ Sách
</Link>
</>
)}
</nav>
{!user && (
+8 -6
View File
@@ -8,6 +8,7 @@ export interface CardNovel {
title: string
authorName: string
coverColor: string | null
coverUrl?: string | null
rating: number
views: number
totalChapters: number
@@ -26,11 +27,11 @@ export function NovelCard({ novel, variant = "default" }: NovelCardProps) {
href={`/truyen/${novel.slug}`}
className="group flex gap-3 rounded-lg border border-border bg-card p-3 transition-colors hover:border-primary/30 hover:bg-accent/50"
>
<div className={`flex h-16 w-12 shrink-0 items-center justify-center rounded bg-gradient-to-br ${novel.coverColor}`}>
<BookOpen className="h-5 w-5 text-background/80" />
<div className="relative h-16 w-12 shrink-0 rounded overflow-hidden">
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-full w-full object-cover" />
</div>
<div className="flex min-w-0 flex-col justify-center">
<h3 className="truncate text-sm font-semibold text-foreground group-hover:text-primary transition-colors">
<h3 title={novel.title} className="line-clamp-2 text-sm font-semibold text-foreground group-hover:text-primary transition-colors">
{novel.title}
</h3>
<p className="text-xs text-muted-foreground">{novel.authorName}</p>
@@ -51,8 +52,9 @@ export function NovelCard({ novel, variant = "default" }: NovelCardProps) {
href={`/truyen/${novel.slug}`}
className="group flex flex-col overflow-hidden rounded-lg border border-border bg-card transition-all hover:border-primary/30 hover:shadow-md"
>
<div className={`relative flex h-44 items-center justify-center bg-gradient-to-br ${novel.coverColor}`}>
<BookOpen className="h-10 w-10 text-background/80" />
<div className="relative h-44 w-full">
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-full w-full object-cover" />
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/60 to-transparent" />
{novel.status === "Đang ra" && (
<span className="absolute right-2 top-2 rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold text-primary-foreground">
Đang ra
@@ -60,7 +62,7 @@ export function NovelCard({ novel, variant = "default" }: NovelCardProps) {
)}
</div>
<div className="flex flex-1 flex-col gap-1 p-3">
<h3 className="line-clamp-1 text-sm font-semibold text-foreground group-hover:text-primary transition-colors text-balance">
<h3 title={novel.title} className="line-clamp-2 h-10 text-sm leading-tight font-semibold text-foreground group-hover:text-primary transition-colors">
{novel.title}
</h3>
<p className="text-xs text-muted-foreground">{novel.authorName}</p>
+155
View File
@@ -0,0 +1,155 @@
"use client"
import { useState, useEffect } from "react"
import { List, Settings2, Headphones, X, Settings, Menu, ArrowUp } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { cn } from "@/lib/utils"
import Link from "next/link"
import { ReadingSettingsContent } from "./reading-settings"
import { TTSPlayer } from "./tts-player"
import { ReaderTOC } from "./reader-toc"
interface ReaderFABProps {
novelId: string
novelSlug: string
// TTS Props
paragraphs: string[]
currentChapter: number
maxChapter: number
chapterTitle: string
}
export function ReaderFAB({ novelId, novelSlug, paragraphs, currentChapter, maxChapter, chapterTitle }: ReaderFABProps) {
const [isOpen, setIsOpen] = useState(false)
const [isTTSOpen, setIsTTSOpen] = useState(false)
const [isTTSExpanded, setIsTTSExpanded] = useState(false)
const [showScrollTop, setShowScrollTop] = useState(false)
useEffect(() => {
const handleScroll = () => {
setShowScrollTop(window.scrollY > 400)
}
window.addEventListener("scroll", handleScroll, { passive: true })
return () => window.removeEventListener("scroll", handleScroll)
}, [])
const handleScrollToTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" })
}
// Reading settings state lifted up for persistence
const [fontSize, setFontSize] = useState(18)
const [lineHeight, setLineHeight] = useState(1.8)
const [letterSpacing, setLetterSpacing] = useState(0)
return (
<>
<div className={cn(
"fixed right-6 z-50 flex flex-col items-center gap-3 transition-all duration-300",
isTTSOpen ? (isTTSExpanded ? "bottom-[12rem]" : "bottom-24") : "bottom-6"
)}>
{/* Main FAB Toggle (Mobile mostly, but works as container) */}
<Button
size="icon"
className="h-14 w-14 rounded-full shadow-lg md:hidden"
onClick={() => setIsOpen(!isOpen)}
>
{isOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
</Button>
{/* Action Items */}
<div
className={cn(
"flex flex-col gap-3 transition-all duration-300 origin-bottom center",
isOpen ? "scale-100 opacity-100" : "scale-0 opacity-0 pointer-events-none md:scale-100 md:opacity-100 md:pointer-events-auto"
)}
>
{/* TTS Toggle */}
<Button
variant={isTTSOpen ? "default" : "secondary"}
size="icon"
className="h-12 w-12 rounded-full shadow-md relative group"
onClick={() => {
setIsTTSOpen(!isTTSOpen)
setIsOpen(false)
}}
>
<Headphones className="h-5 w-5" />
<span className="absolute right-full mr-3 whitespace-nowrap rounded bg-foreground/90 px-2 py-1 text-xs text-background opacity-0 transition-opacity group-hover:opacity-100">
{isTTSOpen ? "Đóng Audio" : "Nghe Audio"}
</span>
</Button>
{/* TOC */}
<ReaderTOC
novelId={novelId}
novelSlug={novelSlug}
currentChapterNumber={currentChapter}
/>
{/* Settings */}
<Popover>
<PopoverTrigger asChild>
<Button
variant="secondary"
size="icon"
className="h-12 w-12 rounded-full shadow-md relative group"
>
<Settings2 className="h-5 w-5" />
<span className="absolute right-full mr-3 whitespace-nowrap rounded bg-foreground/90 px-2 py-1 text-xs text-background opacity-0 transition-opacity group-hover:opacity-100">
Tùy chỉnh
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 mb-2 mr-4 flex" align="end" side="left">
<ReadingSettingsContent
fontSize={fontSize} setFontSize={setFontSize}
lineHeight={lineHeight} setLineHeight={setLineHeight}
letterSpacing={letterSpacing} setLetterSpacing={setLetterSpacing}
/>
</PopoverContent>
</Popover>
{/* Scroll to Top */}
<Button
variant="secondary"
size="icon"
className={cn(
"h-12 w-12 rounded-full shadow-md relative group transition-all duration-300",
showScrollTop ? "opacity-100 scale-100" : "opacity-0 scale-0 pointer-events-none"
)}
onClick={handleScrollToTop}
>
<ArrowUp className="h-5 w-5" />
<span className="absolute right-full mr-3 whitespace-nowrap rounded bg-foreground/90 px-2 py-1 text-xs text-background opacity-0 transition-opacity group-hover:opacity-100">
Lên đu trang
</span>
</Button>
</div>
</div>
{/* Inject styles OUTSIDE the popover so it survives */}
<style>{`
.chapter-content {
font-size: ${fontSize}px !important;
line-height: ${lineHeight} !important;
letter-spacing: ${letterSpacing}px !important;
}
`}</style>
{/* Render the TTS Player connected to this FAB state */}
<TTSPlayer
isOpen={isTTSOpen}
onClose={() => setIsTTSOpen(false)}
isExpanded={isTTSExpanded}
onExpandedChange={setIsTTSExpanded}
paragraphs={paragraphs}
novelSlug={novelSlug}
currentChapter={currentChapter}
maxChapter={maxChapter}
chapterTitle={chapterTitle}
/>
</>
)
}
+153
View File
@@ -0,0 +1,153 @@
"use client"
import { useState, useEffect } from "react"
import Link from "next/link"
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, SheetDescription } from "@/components/ui/sheet"
import { Button } from "@/components/ui/button"
import { List, Loader2, ChevronLeft, ChevronRight } from "lucide-react"
interface ReaderTOCProps {
novelId: string
novelSlug: string
currentChapterNumber: number
}
interface TOCChapter {
id: string
number: number
title: string
}
export function ReaderTOC({ novelId, novelSlug, currentChapterNumber }: ReaderTOCProps) {
const [isOpen, setIsOpen] = useState(false)
const [chapters, setChapters] = useState<TOCChapter[]>([])
const [loading, setLoading] = useState(false)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
// Each page will fetch 100 chapters to make scrolling efficient
const ITEMS_PER_PAGE = 100
// Calculate the initial page where the current chapter belongs
useEffect(() => {
const initialPage = Math.ceil(currentChapterNumber / ITEMS_PER_PAGE)
setCurrentPage(initialPage || 1)
}, [currentChapterNumber])
// Fetch chapters when page changes and TOC is open
useEffect(() => {
if (!isOpen) return
const fetchChapters = async () => {
setLoading(true)
try {
const res = await fetch(`/api/truyen/${novelId}/chapters?page=${currentPage}&limit=${ITEMS_PER_PAGE}`)
if (res.ok) {
const data = await res.json()
setChapters(data.chapters)
setTotalPages(data.totalPages)
}
} catch (error) {
console.error("Failed to load chapters for TOC", error)
} finally {
setLoading(false)
}
}
fetchChapters()
}, [isOpen, currentPage, novelId])
// Optional: Auto-scroll to the current chapter on initial load
useEffect(() => {
if (!loading && isOpen && chapters.length > 0) {
setTimeout(() => {
const activeItem = document.getElementById(`toc-chap-${currentChapterNumber}`)
if (activeItem) {
activeItem.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}, 100)
}
}, [loading, isOpen, chapters, currentChapterNumber])
return (
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<SheetTrigger asChild>
<Button
variant="secondary"
size="icon"
className="h-12 w-12 rounded-full shadow-md relative group"
>
<List className="h-5 w-5" />
<span className="absolute right-full mr-3 whitespace-nowrap rounded bg-foreground/90 px-2 py-1 text-xs text-background opacity-0 transition-opacity group-hover:opacity-100">
Mục lục
</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-[300px] sm:w-[350px] flex flex-col p-4">
<SheetHeader className="pb-4 border-b">
<SheetTitle>Mục lục chương</SheetTitle>
<SheetDescription className="sr-only">Danh sách mục lục chương đưc liệt theo danh sách đ điều hướng thuận tiện</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto custom-scrollbar -mx-4 px-4 py-2 space-y-1 relative">
{loading ? (
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : (
chapters.map((chap) => {
const isActive = chap.number === currentChapterNumber
return (
<Link
key={chap.id}
id={`toc-chap-${chap.number}`}
href={`/truyen/${novelSlug}/${chap.number}`}
onClick={() => setIsOpen(false)}
className={`block px-3 py-2 text-sm rounded-md transition-colors ${
isActive
? 'bg-primary text-primary-foreground font-medium'
: 'hover:bg-muted text-foreground/80'
}`}
>
<span className={isActive ? "text-primary-foreground/90 font-bold mr-2 lg:mr-3" : "text-muted-foreground mr-2 lg:mr-3"}>
{chap.number}.
</span>
<span className="truncate inline-block align-bottom max-w-[80%]">{chap.title}</span>
</Link>
)
})
)}
</div>
{/* Pagination Details */}
{totalPages > 1 && (
<div className="pt-4 border-t flex items-center justify-between gap-2 mt-auto">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
disabled={currentPage <= 1 || loading}
onClick={() => setCurrentPage(p => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-xs text-muted-foreground">
Trang {currentPage} / {totalPages}
</span>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
disabled={currentPage >= totalPages || loading}
onClick={() => setCurrentPage(p => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</SheetContent>
</Sheet>
)
}
+95 -25
View File
@@ -1,25 +1,26 @@
"use client"
import { useState } from "react"
import { Minus, Plus, ALargeSmall } from "lucide-react"
import { Minus, Plus, ALargeSmall, RotateCcw } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
interface ReadingSettingsProps {
fontSize: number
setFontSize: (v: number) => void
lineHeight: number
setLineHeight: (v: number) => void
letterSpacing: number
setLetterSpacing: (v: number) => void
}
export function ReadingSettings() {
const [fontSize, setFontSize] = useState(18)
const [lineHeight, setLineHeight] = useState(1.8)
export function ReadingSettingsContent({
fontSize, setFontSize,
lineHeight, setLineHeight,
letterSpacing, setLetterSpacing
}: ReadingSettingsProps) {
return (
<>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5">
<ALargeSmall className="h-4 w-4" />
<span className="hidden sm:inline">Cài đt</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-64" align="end">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4">
<div>
<label className="mb-2 block text-xs font-medium text-muted-foreground">Cỡ chữ: {fontSize}px</label>
<div className="flex items-center gap-2">
@@ -78,17 +79,86 @@ export function ReadingSettings() {
</Button>
</div>
</div>
</div>
</PopoverContent>
</Popover>
{/* Inject styles */}
<style>{`
.chapter-content {
font-size: ${fontSize}px;
line-height: ${lineHeight};
}
`}</style>
<div>
<label className="mb-2 block text-xs font-medium text-muted-foreground">Khoảng cách chữ: {letterSpacing.toFixed(1)}px</label>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => setLetterSpacing(Math.max(-1, letterSpacing - 0.5))}
disabled={letterSpacing <= -1}
>
<Minus className="h-3 w-3" />
</Button>
<div className="h-1.5 flex-1 rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${((letterSpacing + 1) / 4) * 100}%` }}
/>
</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => setLetterSpacing(Math.min(3, letterSpacing + 0.5))}
disabled={letterSpacing >= 3}
>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
<div className="pt-2">
<Button
variant="ghost"
size="sm"
className="w-full text-xs text-muted-foreground hover:text-foreground"
onClick={() => {
setFontSize(18)
setLineHeight(1.8)
setLetterSpacing(0)
}}
>
<RotateCcw className="mr-2 h-3 w-3" />
Khôi phục mặc đnh
</Button>
</div>
</div>
</>
)
}
export function ReadingSettings() {
const [fontSize, setFontSize] = useState(18)
const [lineHeight, setLineHeight] = useState(1.8)
const [letterSpacing, setLetterSpacing] = useState(0)
return (
<>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5">
<ALargeSmall className="h-4 w-4" />
<span className="hidden sm:inline">Cài đt</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-64" align="end">
<ReadingSettingsContent
fontSize={fontSize} setFontSize={setFontSize}
lineHeight={lineHeight} setLineHeight={setLineHeight}
letterSpacing={letterSpacing} setLetterSpacing={setLetterSpacing}
/>
</PopoverContent>
</Popover>
{/* Inject styles */}
<style>{`
.chapter-content {
font-size: ${fontSize}px !important;
line-height: ${lineHeight} !important;
letter-spacing: ${letterSpacing}px !important;
}
`}</style>
</>
)
}
+146 -31
View File
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation"
import { Play, Pause, Square, SkipForward, SkipBack, Volume2, ChevronDown, ChevronUp, Minus, Plus } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Slider } from "@/components/ui/slider"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { cn } from "@/lib/utils"
interface TTSPlayerProps {
@@ -14,16 +14,20 @@ interface TTSPlayerProps {
currentChapter: number
maxChapter: number
chapterTitle: string
isOpen?: boolean
onClose?: () => void
isExpanded?: boolean
onExpandedChange?: (val: boolean) => void
}
export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, chapterTitle }: TTSPlayerProps) {
export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, chapterTitle, isOpen = true, onClose, isExpanded = false, onExpandedChange }: TTSPlayerProps) {
const router = useRouter()
const [isPlaying, setIsPlaying] = useState(false)
const [isPaused, setIsPaused] = useState(false)
const [currentParagraphIndex, setCurrentParagraphIndex] = useState(0)
const [rate, setRate] = useState(1.0)
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([])
const [selectedVoiceURI, setSelectedVoiceURI] = useState("")
const [isExpanded, setIsExpanded] = useState(false)
const [autoNextChapter, setAutoNextChapter] = useState(true)
const [isSupported, setIsSupported] = useState(false)
@@ -60,10 +64,36 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
)
// Filter out overly robotic generic fallbacks if we have good ones
const goodViVoices = viVoices.filter(v => v.name.includes("Google") || v.name.includes("Microsoft") || v.name.includes("Natural"))
let goodViVoices = viVoices.filter(v =>
v.name.includes("Google") ||
v.name.includes("Microsoft") ||
v.name.includes("Natural") ||
v.name.toLowerCase().includes("female") ||
v.name.toLowerCase().includes("nữ")
)
// Sort to prioritize female voices
goodViVoices.sort((a, b) => {
const aIsFemale = a.name.toLowerCase().includes("female") || a.name.toLowerCase().includes("nữ")
const bIsFemale = b.name.toLowerCase().includes("female") || b.name.toLowerCase().includes("nữ")
if (aIsFemale && !bIsFemale) return -1
if (!aIsFemale && bIsFemale) return 1
return 0
})
const preferredViVoices = goodViVoices.length > 0 ? goodViVoices : viVoices
// Sort preferred voices again just to be sure if not using goodViVoices
if (preferredViVoices === viVoices) {
preferredViVoices.sort((a, b) => {
const aIsFemale = a.name.toLowerCase().includes("female") || a.name.toLowerCase().includes("nữ")
const bIsFemale = b.name.toLowerCase().includes("female") || b.name.toLowerCase().includes("nữ")
if (aIsFemale && !bIsFemale) return -1
if (!aIsFemale && bIsFemale) return 1
return 0
})
}
// If we still have NO vi voices, fallback to ALL voices so the user isn't stuck with an empty list
const allUsable = preferredViVoices.length > 0 ? preferredViVoices : available
@@ -113,7 +143,7 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
// Highlight current paragraph in the DOM
useEffect(() => {
if (!isPlaying) return
if (!isPlaying && !isPaused) return
const articleEl = document.querySelector(".chapter-content")
if (!articleEl) return
@@ -128,11 +158,11 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
activeEl.classList.add("tts-active-paragraph")
activeEl.scrollIntoView({ behavior: "smooth", block: "center" })
}
}, [currentParagraphIndex, isPlaying])
}, [currentParagraphIndex, isPlaying, isPaused])
// Clean highlights when stopped
// Clean highlights when stopped completely (not playing and not paused)
useEffect(() => {
if (!isPlaying) {
if (!isPlaying && !isPaused) {
const articleEl = document.querySelector(".chapter-content")
if (articleEl) {
articleEl.querySelectorAll("p[data-p-index]").forEach((el) => {
@@ -140,13 +170,16 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
})
}
}
}, [isPlaying])
}, [isPlaying, isPaused])
const speakParagraph = useCallback(
(index: number) => {
if (index >= paragraphs.length) {
// Chapter finished
setIsPlaying(false)
setIsPaused(false)
releaseWakeLock()
if (autoNextChapter && currentChapter < maxChapter) {
@@ -179,6 +212,7 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
console.error("TTS Playback Error:", e.error, e)
if (e.error !== "canceled" && e.error !== "interrupted") {
setIsPlaying(false)
setIsPaused(false)
releaseWakeLock()
if (e.error === "synthesis-failed" || e.error === "network") {
@@ -202,10 +236,12 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
// Pause
speechSynthesis.cancel()
setIsPlaying(false)
setIsPaused(true)
releaseWakeLock()
} else {
// Play / Resume
setIsPlaying(true)
setIsPaused(false)
acquireWakeLock()
speakParagraph(currentParagraphIndex)
}
@@ -214,9 +250,11 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
const handleStop = useCallback(() => {
speechSynthesis.cancel()
setIsPlaying(false)
setIsPaused(false)
setCurrentParagraphIndex(0)
releaseWakeLock()
}, [releaseWakeLock])
onClose?.()
}, [releaseWakeLock, onClose])
const handlePrevParagraph = useCallback(() => {
const newIndex = Math.max(0, currentParagraphIndex - 1)
@@ -236,6 +274,51 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
}
}, [currentParagraphIndex, paragraphs.length, isPlaying, speakParagraph])
// Listen for clicks on paragraphs to jump TTS
useEffect(() => {
if (!isOpen) {
document.querySelector(".chapter-content")?.classList.remove("tts-selection-mode")
return
}
const articleEl = document.querySelector(".chapter-content")
if (!articleEl) return
articleEl.classList.add("tts-selection-mode")
const handleParagraphClick = (e: Event) => {
const target = e.currentTarget as HTMLElement
const idxStr = target.getAttribute("data-p-index")
if (idxStr !== null) {
const index = parseInt(idxStr, 10)
// Stop current speech
speechSynthesis.cancel()
// Set new index and play
setCurrentParagraphIndex(index)
setIsPlaying(true)
setIsPaused(false)
acquireWakeLock()
// Since speakParagraph is a useCallback with all valid deps, it's safe to call here:
speakParagraph(index)
}
}
const pElements = articleEl.querySelectorAll("p[data-p-index]")
pElements.forEach((el) => {
el.addEventListener("click", handleParagraphClick)
})
return () => {
articleEl.classList.remove("tts-selection-mode")
pElements.forEach((el) => {
el.removeEventListener("click", handleParagraphClick)
})
}
}, [isOpen, acquireWakeLock, speakParagraph])
// Auto-play TTS when coming from previous chapter auto-advance
useEffect(() => {
if (!isSupported) return
@@ -259,7 +342,7 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
return (
<>
{/* Floating TTS bar */}
<div className="fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-card/95 backdrop-blur-md shadow-lg">
<div className={cn("fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-card/95 backdrop-blur-md shadow-[0_-4px_6px_-1px_rgb(0,0,0,0.1)] transition-transform duration-300", !isOpen && "translate-y-full")}>
{/* Progress bar */}
<div className="h-0.5 w-full bg-muted">
<div
@@ -301,6 +384,22 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
</span>
</div>
{/* Voice Selection (Always accessible) */}
<div className="hidden md:block w-32 shrink-0">
<Select value={selectedVoiceURI} onValueChange={setSelectedVoiceURI}>
<SelectTrigger className="h-8 text-xs bg-muted/50 border-0">
<SelectValue placeholder="Chọn giọng..." />
</SelectTrigger>
<SelectContent>
{voices.map((voice) => (
<SelectItem key={voice.voiceURI} value={voice.voiceURI} className="text-[10px] py-1">
{voice.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Speed control */}
<div className="hidden items-center gap-1 sm:flex">
<Button
@@ -327,17 +426,17 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
</div>
{/* Expand/Collapse */}
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsExpanded(!isExpanded)}>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onExpandedChange?.(!isExpanded)}>
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
</Button>
</div>
{/* Expanded settings */}
{isExpanded && (
<div className="mt-3 flex flex-col gap-3 border-t border-border pt-3">
<div className="mt-3 flex flex-col gap-4 border-t border-border pt-4">
{/* Speed on mobile */}
<div className="flex items-center gap-3 sm:hidden">
<span className="text-xs text-muted-foreground w-16 shrink-0">Toc do:</span>
<span className="text-xs font-medium text-muted-foreground w-16 shrink-0">Tốc đ:</span>
<Slider
value={[rate]}
min={0.5}
@@ -349,23 +448,25 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
<span className="w-10 text-right text-xs font-medium">{rate.toFixed(2)}x</span>
</div>
{/* Voice selector */}
{voices.length > 1 && (
{/* Voice selector (Mobile only, desktop has it on main bar) */}
<div className="flex flex-col gap-1.5 md:hidden">
<label className="text-xs font-medium text-muted-foreground">Giọng đc:</label>
<div className="flex items-center gap-3">
<Volume2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<select
className="flex-1 rounded-md border border-input bg-background px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
value={selectedVoiceURI}
onChange={(e) => setSelectedVoiceURI(e.target.value)}
>
{voices.map((v) => (
<option key={v.voiceURI} value={v.voiceURI}>
{v.name} ({v.lang})
</option>
))}
</select>
<Select value={selectedVoiceURI} onValueChange={setSelectedVoiceURI}>
<SelectTrigger className="h-9 w-full bg-background text-xs">
<SelectValue placeholder="Chọn giọng đọc..." />
</SelectTrigger>
<SelectContent>
{voices.map((voice) => (
<SelectItem key={voice.voiceURI} value={voice.voiceURI} className="text-xs">
{voice.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{/* Auto next chapter toggle */}
<label className="flex cursor-pointer items-center gap-3">
@@ -388,7 +489,7 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
/>
</div>
<span className="text-xs text-muted-foreground">
Tu dong chuyen chuong {currentChapter < maxChapter ? `(chuong ${currentChapter + 1})` : "(da la chuong cuoi)"}
Tự đng chuyển chương {currentChapter < maxChapter ? `(chương ${currentChapter + 1})` : "(đã là chương cui)"}
</span>
</label>
</div>
@@ -396,8 +497,8 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
</div>
</div>
{/* Spacer so content isn't hidden behind the player bar */}
<div className={cn("h-16", isExpanded && "h-44")} />
{/* Spacer so content isn't hidden behind the player bar - only active when open */}
<div className={cn("transition-all duration-300", isOpen ? (isExpanded ? "h-44" : "h-16") : "h-0")} />
{/* TTS highlight styles */}
<style>{`
@@ -410,6 +511,20 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
margin-right: -0.75rem;
transition: background 0.3s ease, color 0.3s ease;
}
.tts-selection-mode p[data-p-index] {
cursor: pointer;
transition: background 0.2s ease, padding 0.2s ease, margin 0.2s ease;
border-radius: 0.375rem;
padding-left: 0.75rem;
padding-right: 0.75rem;
margin-left: -0.75rem;
margin-right: -0.75rem;
}
.tts-selection-mode p[data-p-index]:hover:not(.tts-active-paragraph) {
background: hsl(var(--primary) / 0.15);
}
`}</style>
</>
)