Refactor code structure for improved readability and maintainability
This commit is contained in:
+74
-18
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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>{`
|
||||
|
||||
Reference in New Issue
Block a user