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
+74 -18
View File
@@ -8,6 +8,9 @@ interface ChapterListProps {
id: string
novelId: string
number: number
volumeNumber?: number | null
volumeTitle?: string | null
volumeChapterNumber?: number | null
title: string
createdAt: string
views: number
@@ -31,28 +34,64 @@ const generatePagination = (currentPage: number, totalPages: number) => {
return [1, '...', currentPage - 1, currentPage, currentPage + 1, '...', totalPages]
}
const generateMobilePagination = (currentPage: number, totalPages: number) => {
if (totalPages <= 3) {
return Array.from({ length: totalPages }, (_, i) => i + 1)
}
if (currentPage <= 1) {
return [1, 2, '...']
}
if (currentPage >= totalPages) {
return ['...', totalPages - 1, totalPages]
}
return [currentPage - 1, currentPage, currentPage + 1]
}
export function ChapterList({ chapters, novelSlug, currentPage, totalPages, totalChapters }: ChapterListProps) {
let lastVolumeKey: string | null = null
return (
<div className="flex flex-col">
{chapters.map((chapter) => (
<Link
key={chapter.id}
href={`/truyen/${novelSlug}/${chapter.number}`}
className="flex items-center justify-between border-b border-border px-2 py-3 text-sm transition-colors hover:bg-muted/50 last:border-0"
>
<div className="flex items-center gap-2 min-w-0">
<span className="shrink-0 font-medium text-muted-foreground">Ch. {chapter.number}</span>
<span className="truncate text-foreground">{chapter.title}</span>
{chapters.map((chapter) => {
const currentVolumeKey = chapter.volumeNumber || chapter.volumeTitle
? `${chapter.volumeNumber ?? "no-num"}-${chapter.volumeTitle ?? "no-title"}`
: null
const showVolumeHeader = currentVolumeKey !== null && currentVolumeKey !== lastVolumeKey
if (currentVolumeKey !== null) {
lastVolumeKey = currentVolumeKey
}
return (
<div key={chapter.id}>
{showVolumeHeader && (
<div className="border-b border-border bg-muted/40 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-primary">
{chapter.volumeTitle || `Quyển ${chapter.volumeNumber}`}
</div>
)}
<Link
href={`/truyen/${novelSlug}/${chapter.number}`}
className="flex items-center justify-between border-b border-border px-2 py-3 text-sm transition-colors hover:bg-muted/50 last:border-0"
>
<div className="flex items-center gap-2 min-w-0">
<span className="shrink-0 font-medium text-muted-foreground">
{chapter.volumeChapterNumber ? `Ch. ${chapter.volumeChapterNumber}` : `Ch. ${chapter.number}`}
</span>
<span className="truncate text-foreground">{chapter.title}</span>
</div>
<div className="flex shrink-0 items-center gap-3 text-xs text-muted-foreground">
<span className="hidden items-center gap-1 sm:flex">
<Eye className="h-3 w-3" />
{formatViews(chapter.views)}
</span>
<span>{chapter.createdAt}</span>
</div>
</Link>
</div>
<div className="flex shrink-0 items-center gap-3 text-xs text-muted-foreground">
<span className="hidden items-center gap-1 sm:flex">
<Eye className="h-3 w-3" />
{formatViews(chapter.views)}
</span>
<span>{chapter.createdAt}</span>
</div>
</Link>
))}
)
})}
{totalPages > 1 && (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 border-t border-border p-4 bg-muted/10">
@@ -68,6 +107,23 @@ export function ChapterList({ chapters, novelSlug, currentPage, totalPages, tota
Trước
</Link>
<div className="flex items-center gap-1 sm:hidden">
{generateMobilePagination(currentPage, totalPages).map((p, i) => (
<div key={`mobile-${i}`}>
{p === '...' ? (
<span className="px-2 text-muted-foreground">...</span>
) : (
<Link
href={`/truyen/${novelSlug}?page=${p}`}
className={`inline-flex h-9 w-9 items-center justify-center whitespace-nowrap rounded-md border text-sm font-medium shadow-sm transition-colors ${currentPage === p ? 'bg-primary text-primary-foreground border-primary hover:bg-primary/90' : 'bg-background border-input hover:bg-accent hover:text-accent-foreground'}`}
>
{p}
</Link>
)}
</div>
))}
</div>
{generatePagination(currentPage, totalPages).map((p, i) => (
<div key={i} className="hidden sm:block">
{p === '...' ? (
+96 -2
View File
@@ -2,8 +2,8 @@
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import { useState } from "react"
import { BookOpen, Menu, X, Search, User as UserIcon, LogOut, BookMarked, Shield } from "lucide-react"
import { useEffect, useState } from "react"
import { BookOpen, Menu, Search, 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"
@@ -17,18 +17,76 @@ const navLinks = [
{ label: "Danh Sách", href: "/tim-kiem" },
]
type SearchSuggestion = {
id: string
title: string
slug: string
authorName: string
coverUrl?: string | null
series?: { id: string; name: string } | null
}
function roleLabel(role?: "USER" | "MOD" | "ADMIN") {
if (role === "ADMIN") return "Quản trị viên"
if (role === "MOD") return "Kiểm duyệt viên"
return "Thành viên"
}
export function Header() {
const pathname = usePathname()
const router = useRouter()
const { user, logout } = useAuth()
const [searchQuery, setSearchQuery] = useState("")
const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([])
const [isSearchFocused, setIsSearchFocused] = useState(false)
const [open, setOpen] = useState(false)
useEffect(() => {
const query = searchQuery.trim()
if (query.length < 2) {
setSuggestions([])
return
}
const controller = new AbortController()
const timeoutId = setTimeout(async () => {
try {
const res = await fetch(`/api/truyen/suggest?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
})
if (!res.ok) {
setSuggestions([])
return
}
const data = await res.json()
setSuggestions(Array.isArray(data) ? data : [])
} catch {
setSuggestions([])
}
}, 250)
return () => {
controller.abort()
clearTimeout(timeoutId)
}
}, [searchQuery])
const goToSuggestion = (slug: string) => {
router.push(`/truyen/${slug}`)
setSearchQuery("")
setSuggestions([])
setIsSearchFocused(false)
}
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
if (searchQuery.trim()) {
router.push(`/tim-kiem?q=${encodeURIComponent(searchQuery.trim())}`)
setSearchQuery("")
setSuggestions([])
setIsSearchFocused(false)
}
}
@@ -68,7 +126,41 @@ export function Header() {
className="h-9 pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setTimeout(() => setIsSearchFocused(false), 120)}
/>
{isSearchFocused && searchQuery.trim().length >= 2 && (
<div className="absolute top-[calc(100%+6px)] z-50 w-full overflow-hidden rounded-md border border-border bg-popover shadow-lg">
{suggestions.length > 0 ? (
suggestions.map((item) => (
<button
key={item.id}
type="button"
onClick={() => goToSuggestion(item.slug)}
className="flex w-full items-center gap-3 border-b border-border px-3 py-2 text-left last:border-b-0 hover:bg-muted/40"
>
<img
src={item.coverUrl || "/default-cover.svg"}
alt={item.title}
className="h-12 w-9 rounded-sm bg-muted object-contain"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{item.title}</p>
<p className="truncate text-xs text-muted-foreground">{item.authorName}</p>
</div>
{item.series?.name && (
<span className="max-w-[120px] truncate text-[11px] font-medium text-primary">
{item.series.name}
</span>
)}
</button>
))
) : (
<div className="px-3 py-2 text-sm text-muted-foreground">Không tìm thấy kết quả phù hợp.</div>
)}
</div>
)}
</div>
</form>
@@ -93,6 +185,7 @@ export function Header() {
<div className="px-2 py-1.5">
<p className="text-sm font-medium text-foreground">{user.username}</p>
<p className="text-xs text-muted-foreground">{user.email}</p>
<p className="text-xs text-primary">Loại tài khoản: {roleLabel(user.role)}</p>
</div>
<DropdownMenuSeparator />
{(user.role === "MOD" || user.role === "ADMIN") && (
@@ -208,6 +301,7 @@ export function Header() {
<div>
<p className="text-sm font-medium text-foreground">{user.username}</p>
<p className="text-xs text-muted-foreground">{user.email}</p>
<p className="text-xs text-primary">Loại tài khoản: {roleLabel(user.role)}</p>
</div>
</div>
<Button variant="ghost" size="sm" className="w-full justify-start text-destructive" onClick={() => { logout(); setOpen(false) }}>
+13 -10
View File
@@ -1,6 +1,7 @@
import Link from "next/link"
import { BookOpen, Eye, Star } from "lucide-react"
import { formatViews } from "@/lib/utils"
import { getNovelStatusBadgeClass } from "@/lib/novel-status"
export interface CardNovel {
id: string
@@ -27,14 +28,19 @@ 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="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 className="relative h-16 w-12 shrink-0 overflow-hidden rounded bg-muted">
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-full w-full object-contain" />
</div>
<div className="flex min-w-0 flex-col justify-center">
<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>
<div className="mt-1">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ${getNovelStatusBadgeClass(novel.status)}`}>
{novel.status}
</span>
</div>
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-0.5">
<Star className="h-3 w-3 fill-primary text-primary" />
@@ -52,14 +58,11 @@ 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 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
</span>
)}
<div className="relative h-44 w-full bg-muted">
<img src={novel.coverUrl || "/default-cover.svg"} alt={novel.title} className="h-full w-full object-contain" />
<span className={`absolute right-2 top-2 rounded-full px-2 py-0.5 text-[10px] font-semibold ${getNovelStatusBadgeClass(novel.status)}`}>
{novel.status}
</span>
</div>
<div className="flex flex-1 flex-col gap-1 p-3">
<h3 title={novel.title} className="line-clamp-2 h-10 text-sm leading-tight font-semibold text-foreground group-hover:text-primary transition-colors">
+45 -18
View File
@@ -1,11 +1,10 @@
"use client"
import { useState, useEffect } from "react"
import { List, Settings2, Headphones, X, Settings, Menu, ArrowUp } from "lucide-react"
import { Settings2, Headphones, X, 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"
@@ -25,14 +24,41 @@ export function ReaderFAB({ novelId, novelSlug, paragraphs, currentChapter, maxC
const [isTTSOpen, setIsTTSOpen] = useState(false)
const [isTTSExpanded, setIsTTSExpanded] = useState(false)
const [showScrollTop, setShowScrollTop] = useState(false)
const [isMobileControlsVisible, setIsMobileControlsVisible] = useState(true)
useEffect(() => {
let lastScrollY = window.scrollY
const handleScroll = () => {
setShowScrollTop(window.scrollY > 400)
const currentY = window.scrollY
setShowScrollTop(currentY > 400)
const isMobile = window.innerWidth < 768
if (!isMobile) {
setIsMobileControlsVisible(true)
lastScrollY = currentY
return
}
const delta = currentY - lastScrollY
if (Math.abs(delta) < 12) {
return
}
if (currentY < 120) {
setIsMobileControlsVisible(true)
} else if (delta > 0 && !isOpen && !isTTSOpen) {
setIsMobileControlsVisible(false)
} else if (delta < 0) {
setIsMobileControlsVisible(true)
}
lastScrollY = currentY
}
window.addEventListener("scroll", handleScroll, { passive: true })
return () => window.removeEventListener("scroll", handleScroll)
}, [])
}, [isOpen, isTTSOpen])
const handleScrollToTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" })
@@ -46,16 +72,17 @@ export function ReaderFAB({ novelId, novelSlug, paragraphs, currentChapter, maxC
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"
"fixed right-3 z-50 flex flex-col items-center gap-2.5 transition-all duration-300 md:right-6 md:gap-3",
isTTSOpen ? (isTTSExpanded ? "bottom-[10.5rem] md:bottom-[12rem]" : "bottom-[4.75rem] md:bottom-24") : "bottom-3 md:bottom-6",
isMobileControlsVisible ? "max-md:translate-y-0 max-md:opacity-100" : "max-md:translate-y-20 max-md:opacity-0 max-md:pointer-events-none"
)}>
{/* Main FAB Toggle (Mobile mostly, but works as container) */}
<Button
size="icon"
className="h-14 w-14 rounded-full shadow-lg md:hidden"
className="h-11 w-11 rounded-full shadow-lg md:hidden"
onClick={() => setIsOpen(!isOpen)}
>
{isOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
{isOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</Button>
{/* Action Items */}
@@ -69,14 +96,14 @@ export function ReaderFAB({ novelId, novelSlug, paragraphs, currentChapter, maxC
<Button
variant={isTTSOpen ? "default" : "secondary"}
size="icon"
className="h-12 w-12 rounded-full shadow-md relative group"
className="h-10 w-10 rounded-full shadow-md relative group md:h-12 md:w-12"
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">
<Headphones className="h-4 w-4 md:h-5 md:w-5" />
<span className="absolute right-full mr-3 hidden whitespace-nowrap rounded bg-foreground/90 px-2 py-1 text-xs text-background opacity-0 transition-opacity group-hover:opacity-100 md:inline">
{isTTSOpen ? "Đóng Audio" : "Nghe Audio"}
</span>
</Button>
@@ -94,15 +121,15 @@ export function ReaderFAB({ novelId, novelSlug, paragraphs, currentChapter, maxC
<Button
variant="secondary"
size="icon"
className="h-12 w-12 rounded-full shadow-md relative group"
className="h-10 w-10 rounded-full shadow-md relative group md:h-12 md:w-12"
>
<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">
<Settings2 className="h-4 w-4 md:h-5 md:w-5" />
<span className="absolute right-full mr-3 hidden whitespace-nowrap rounded bg-foreground/90 px-2 py-1 text-xs text-background opacity-0 transition-opacity group-hover:opacity-100 md:inline">
Tùy chỉnh
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 mb-2 mr-4 flex" align="end" side="left">
<PopoverContent className="mb-2 mr-2 flex w-64 md:mr-4" align="end" side="left">
<ReadingSettingsContent
fontSize={fontSize} setFontSize={setFontSize}
lineHeight={lineHeight} setLineHeight={setLineHeight}
@@ -116,13 +143,13 @@ export function ReaderFAB({ novelId, novelSlug, paragraphs, currentChapter, maxC
variant="secondary"
size="icon"
className={cn(
"h-12 w-12 rounded-full shadow-md relative group transition-all duration-300",
"h-10 w-10 rounded-full shadow-md relative group transition-all duration-300 md:h-12 md:w-12",
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">
<ArrowUp className="h-4 w-4 md:h-5 md:w-5" />
<span className="absolute right-full mr-3 hidden whitespace-nowrap rounded bg-foreground/90 px-2 py-1 text-xs text-background opacity-0 transition-opacity group-hover:opacity-100 md:inline">
Lên đu trang
</span>
</Button>
+41 -21
View File
@@ -16,6 +16,9 @@ interface TOCChapter {
id: string
number: number
title: string
volumeNumber?: number | null
volumeTitle?: string | null
volumeChapterNumber?: number | null
}
export function ReaderTOC({ novelId, novelSlug, currentChapterNumber }: ReaderTOCProps) {
@@ -76,10 +79,10 @@ export function ReaderTOC({ novelId, novelSlug, currentChapterNumber }: ReaderTO
<Button
variant="secondary"
size="icon"
className="h-12 w-12 rounded-full shadow-md relative group"
className="relative h-10 w-10 rounded-full shadow-md group md:h-12 md:w-12"
>
<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">
<List className="h-4 w-4 md:h-5 md:w-5" />
<span className="absolute right-full mr-3 hidden whitespace-nowrap rounded bg-foreground/90 px-2 py-1 text-xs text-background opacity-0 transition-opacity group-hover:opacity-100 md:inline">
Mục lục
</span>
</Button>
@@ -97,27 +100,44 @@ export function ReaderTOC({ novelId, novelSlug, currentChapterNumber }: ReaderTO
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : (
chapters.map((chap) => {
(() => {
let lastVolumeKey: string | null = null
return chapters.map((chap) => {
const isActive = chap.number === currentChapterNumber
const volumeKey = chap.volumeNumber || chap.volumeTitle
? `${chap.volumeNumber ?? "no-num"}-${chap.volumeTitle ?? "no-title"}`
: null
const showVolumeHeader = volumeKey !== null && volumeKey !== lastVolumeKey
if (volumeKey !== null) {
lastVolumeKey = volumeKey
}
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 key={chap.id}>
{showVolumeHeader && (
<div className="px-3 pt-2 pb-1 text-xs font-semibold uppercase tracking-wide text-primary">
{chap.volumeTitle || `Quyển ${chap.volumeNumber}`}
</div>
)}
<Link
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.volumeChapterNumber || chap.number}.
</span>
<span className="truncate inline-block align-bottom max-w-[80%]">{chap.title}</span>
</Link>
</div>
)
})
})
})()
)}
</div>
+8 -8
View File
@@ -342,7 +342,7 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
return (
<>
{/* Floating TTS bar */}
<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")}>
<div className={cn("fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-card/95 pb-[env(safe-area-inset-bottom)] 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
@@ -351,25 +351,25 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
/>
</div>
<div className="mx-auto max-w-3xl px-4 py-2">
<div className="mx-auto max-w-3xl px-2 py-1.5 sm:px-4 sm:py-2">
{/* Compact bar */}
<div className="flex items-center gap-3">
{/* Play controls */}
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handlePrevParagraph} disabled={currentParagraphIndex <= 0}>
<Button variant="ghost" size="icon" className="h-7 w-7 sm:h-8 sm:w-8" onClick={handlePrevParagraph} disabled={currentParagraphIndex <= 0}>
<SkipBack className="h-4 w-4" />
</Button>
<Button
size="icon"
className="h-9 w-9 rounded-full"
className="h-8 w-8 rounded-full sm:h-9 sm:w-9"
onClick={handlePlay}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4 ml-0.5" />}
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleNextParagraph} disabled={currentParagraphIndex >= paragraphs.length - 1}>
<Button variant="ghost" size="icon" className="h-7 w-7 sm:h-8 sm:w-8" onClick={handleNextParagraph} disabled={currentParagraphIndex >= paragraphs.length - 1}>
<SkipForward className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleStop}>
<Button variant="ghost" size="icon" className="h-7 w-7 sm:h-8 sm:w-8" onClick={handleStop}>
<Square className="h-3.5 w-3.5" />
</Button>
</div>
@@ -426,7 +426,7 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
</div>
{/* Expand/Collapse */}
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onExpandedChange?.(!isExpanded)}>
<Button variant="ghost" size="icon" className="h-7 w-7 sm:h-8 sm:w-8" onClick={() => onExpandedChange?.(!isExpanded)}>
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
</Button>
</div>
@@ -498,7 +498,7 @@ export function TTSPlayer({ paragraphs, novelSlug, currentChapter, maxChapter, c
</div>
{/* 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")} />
<div className={cn("transition-all duration-300", isOpen ? (isExpanded ? "h-52 sm:h-44" : "h-16") : "h-0")} />
{/* TTS highlight styles */}
<style>{`