feat: update Audiobook component with improved status icons and loading indicators
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { Book, BookOpen, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp, Play, Square, Pencil, Check, X, Loader2, Zap, Settings2, PanelLeftClose, PanelLeftOpen, Wand2, Volume2, Bot, Flame, Headphones, Clock, CheckCircle2, AlertCircle, CircleDot } from 'lucide-react'
|
||||
import { Book, BookOpen, Plus, Trash2, RefreshCw, Download, ChevronDown, ChevronUp, Play, Square, Pencil, Check, X, Loader2, Zap, Settings2, PanelLeftClose, PanelLeftOpen, Wand2, Volume2, Bot, Headphones, Clock, CheckCircle2, AlertCircle, CircleDot } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
@@ -33,18 +33,26 @@ function LazyAudioPlayer({ audioUrl, jobId, compact }: { audioUrl: string; jobId
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<string, { Icon: React.ElementType; iconCls: string; cardBg: string; cardBgSel: string }> = {
|
||||
pending: { Icon: Clock, iconCls: 'h-3 w-3 text-muted-foreground', cardBg: 'bg-background/50', cardBgSel: 'bg-muted' },
|
||||
analyzing: { Icon: Loader2, iconCls: 'h-3 w-3 text-blue-500 animate-spin', cardBg: 'bg-blue-500/5', cardBgSel: 'bg-blue-500/20' },
|
||||
characters_ready: { Icon: Loader2, iconCls: 'h-3 w-3 text-blue-500 animate-spin', cardBg: 'bg-blue-500/5', cardBgSel: 'bg-blue-500/20' },
|
||||
parsing: { Icon: Loader2, iconCls: 'h-3 w-3 text-amber-500 animate-spin', cardBg: 'bg-amber-500/5', cardBgSel: 'bg-amber-500/20' },
|
||||
processing: { Icon: Loader2, iconCls: 'h-3 w-3 text-amber-500 animate-spin', cardBg: 'bg-amber-500/5', cardBgSel: 'bg-amber-500/20' },
|
||||
ready: { Icon: CircleDot, iconCls: 'h-3 w-3 text-green-500', cardBg: 'bg-green-500/5', cardBgSel: 'bg-green-500/20' },
|
||||
generating: { Icon: Loader2, iconCls: 'h-3 w-3 text-amber-500 animate-spin', cardBg: 'bg-amber-500/5', cardBgSel: 'bg-amber-500/20' },
|
||||
done: { Icon: CheckCircle2, iconCls: 'h-3 w-3 text-emerald-500', cardBg: 'bg-emerald-500/5', cardBgSel: 'bg-emerald-500/20' },
|
||||
error: { Icon: AlertCircle, iconCls: 'h-3 w-3 text-destructive', cardBg: 'bg-destructive/5', cardBgSel: 'bg-destructive/20' },
|
||||
pending: { Icon: Clock, iconCls: 'h-3 w-3 text-muted-foreground/40', cardBg: 'bg-background/50', cardBgSel: 'bg-muted' },
|
||||
analyzing: { Icon: Loader2, iconCls: 'h-3 w-3 text-sky-500 animate-spin', cardBg: 'bg-sky-500/5', cardBgSel: 'bg-sky-500/20' },
|
||||
characters_ready: { Icon: Loader2, iconCls: 'h-3 w-3 text-violet-500 animate-spin', cardBg: 'bg-violet-500/5', cardBgSel: 'bg-violet-500/20' },
|
||||
parsing: { Icon: Loader2, iconCls: 'h-3 w-3 text-amber-500 animate-spin', cardBg: 'bg-amber-500/5', cardBgSel: 'bg-amber-500/20' },
|
||||
processing: { Icon: Loader2, iconCls: 'h-3 w-3 text-red-500 animate-spin', cardBg: 'bg-orange-500/5', cardBgSel: 'bg-orange-500/20' },
|
||||
ready: { Icon: CircleDot, iconCls: 'h-3 w-3 text-cyan-500', cardBg: 'bg-cyan-500/5', cardBgSel: 'bg-cyan-500/20' },
|
||||
generating: { Icon: Loader2, iconCls: 'h-3 w-3 text-amber-400 animate-spin', cardBg: 'bg-amber-400/5', cardBgSel: 'bg-amber-400/20' },
|
||||
done: { Icon: CheckCircle2, iconCls: 'h-3 w-3 text-emerald-500', cardBg: 'bg-emerald-500/5', cardBgSel: 'bg-emerald-500/20' },
|
||||
error: { Icon: AlertCircle, iconCls: 'h-3 w-3 text-destructive', cardBg: 'bg-destructive/5', cardBgSel: 'bg-destructive/20' },
|
||||
}
|
||||
|
||||
const STEP_HINT_STATUSES = ['pending', 'analyzing', 'characters_ready', 'ready', 'generating']
|
||||
|
||||
function NsfwIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<text x="12" y="15.5" textAnchor="middle" fontSize="8" fontWeight="900" fill="currentColor" stroke="none">18+</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SequentialPlayer({
|
||||
segments,
|
||||
@@ -839,7 +847,7 @@ function NSFWScriptDialog({ open, onClose, onCreated }: { open: boolean; onClose
|
||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Flame className="h-4 w-4 text-orange-500" />
|
||||
<NsfwIcon className="h-4 w-4 text-red-500" />
|
||||
NSFW 生成剧本
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -1046,8 +1054,8 @@ function ProjectListSidebar({
|
||||
</Button>
|
||||
)}
|
||||
{hasNsfwAccess && (
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7 text-orange-500 hover:text-orange-600" onClick={onNSFWScript} title="NSFW 生成剧本">
|
||||
<Flame className="h-4 w-4" />
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7 text-red-500 hover:text-orange-600" onClick={onNSFWScript} title="NSFW 生成剧本">
|
||||
<NsfwIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onAIScript} title="AI 生成剧本">
|
||||
@@ -1068,28 +1076,27 @@ function ProjectListSidebar({
|
||||
const ProjectIcon = p.source_type === 'epub'
|
||||
? BookOpen
|
||||
: isNsfw
|
||||
? Flame
|
||||
? NsfwIcon
|
||||
: p.source_type === 'ai_generated'
|
||||
? Wand2
|
||||
? Zap
|
||||
: Book
|
||||
const iconClass = isNsfw
|
||||
? 'h-3.5 w-3.5 shrink-0 text-orange-500'
|
||||
: p.source_type === 'ai_generated'
|
||||
? 'h-3.5 w-3.5 shrink-0 text-violet-500'
|
||||
: 'h-3.5 w-3.5 shrink-0 text-muted-foreground'
|
||||
? 'h-3.5 w-3.5 shrink-0 text-red-500'
|
||||
: 'h-3.5 w-3.5 shrink-0 text-muted-foreground'
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => onSelect(p.id)}
|
||||
className={`w-full text-left rounded-lg border p-3 flex flex-col gap-1.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 ${
|
||||
selectedId === p.id
|
||||
? `${STATUS_CONFIG[p.status]?.cardBgSel ?? 'bg-background'} border-border shadow-sm`
|
||||
: `${STATUS_CONFIG[p.status]?.cardBg ?? 'bg-background/50'} border-border/40 hover:opacity-90`
|
||||
? 'bg-muted/60 border-border shadow-sm'
|
||||
: 'bg-muted/20 border-border/30 hover:bg-muted/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{(() => { const sc = STATUS_CONFIG[p.status]; return sc ? <sc.Icon className={`${sc.iconCls} shrink-0`} /> : null })()}
|
||||
<ProjectIcon className={iconClass} />
|
||||
<span className="text-sm font-medium truncate flex-1 text-center" title={p.title}>{p.title}</span>
|
||||
<span className="text-sm font-medium truncate flex-1" title={p.title}>{p.title}</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
@@ -1189,9 +1196,9 @@ function CharactersPanel({
|
||||
|
||||
if (collapsed && hasChaptersOutline) {
|
||||
return (
|
||||
<div className="w-44 shrink-0 flex flex-col rounded-xl border border-emerald-500/20 bg-emerald-500/5 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-2 py-2 shrink-0 border-b border-emerald-500/20">
|
||||
<span className="text-xs font-medium text-emerald-400/80 truncate">
|
||||
<div className="w-44 shrink-0 flex flex-col rounded-xl border border-border/40 bg-muted/20 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-2 py-2 shrink-0 border-b border-border/30">
|
||||
<span className="text-xs font-medium text-muted-foreground truncate">
|
||||
{t('projectCard.chapters.title', { count: detail!.chapters.length })}
|
||||
</span>
|
||||
<Button size="icon" variant="ghost" className="h-6 w-6 shrink-0 ml-1" onClick={onToggle}>
|
||||
@@ -1201,7 +1208,7 @@ function CharactersPanel({
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2 space-y-1.5">
|
||||
{detail!.chapters.map(ch => {
|
||||
const chTitle = ch.title || t('projectCard.chapters.defaultTitle', { index: ch.chapter_index + 1 })
|
||||
const dotClass = ch.status === 'done' ? 'bg-green-400' : ch.status === 'error' ? 'bg-destructive' : ch.status === 'parsing' || ch.status === 'generating' ? 'bg-amber-400' : ch.status === 'ready' ? 'bg-blue-400' : 'bg-muted-foreground/50'
|
||||
const dotClass = ch.status === 'error' ? 'bg-destructive' : ch.status === 'parsing' || ch.status === 'generating' ? 'bg-muted-foreground/80' : ch.status === 'done' ? 'bg-muted-foreground/30' : ch.status === 'ready' ? 'bg-muted-foreground/50' : 'bg-muted-foreground/25'
|
||||
return (
|
||||
<button
|
||||
key={ch.id}
|
||||
@@ -1570,9 +1577,9 @@ function ChaptersPanel({
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className="flex-1 flex flex-col rounded-xl border border-emerald-500/20 bg-emerald-500/5 overflow-hidden">
|
||||
<div className="flex-1 flex flex-col rounded-xl border border-border/40 bg-muted/20 overflow-hidden">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between px-3 py-2 shrink-0">
|
||||
<span className="text-xs font-medium text-emerald-400/80">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t('projectCard.chapters.title', { count: detail?.chapters.length ?? 0 })}
|
||||
</span>
|
||||
{hasChapters && (
|
||||
@@ -1608,7 +1615,7 @@ function ChaptersPanel({
|
||||
{!hasChapters ? (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground" />
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef}>
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2 space-y-2" ref={scrollContainerRef}>
|
||||
{detail!.chapters.map(ch => {
|
||||
const chSegs = segments.filter(s => s.chapter_index === ch.chapter_index)
|
||||
const chDone = chSegs.filter(s => s.status === 'done').length
|
||||
@@ -1623,11 +1630,11 @@ function ChaptersPanel({
|
||||
else next.add(ch.id)
|
||||
return next
|
||||
})
|
||||
const chCardBorder = ch.status === 'error' ? 'border-destructive/40' : ch.status === 'parsing' || ch.status === 'generating' || chGenerating ? 'border-muted-foreground/50' : ch.status === 'done' || (ch.status === 'ready' && chAllDone) ? 'border-border/30' : ch.status === 'ready' ? 'border-muted-foreground/30' : 'border-border/25'
|
||||
return (
|
||||
<div key={ch.id} id={`ch-${ch.id}`}>
|
||||
{/* Chapter header — flat, full-width, click to expand */}
|
||||
<div key={ch.id} id={`ch-${ch.id}`} className={`rounded-xl border overflow-clip ${chCardBorder}`}>
|
||||
<button
|
||||
className="sticky top-0 z-10 w-full flex items-center gap-2 px-3 py-2.5 bg-emerald-500/5 backdrop-blur-sm hover:bg-emerald-500/10 border-b text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||
className="sticky top-0 z-10 w-full flex items-center gap-2 px-3 py-2.5 bg-background/90 backdrop-blur-sm hover:bg-muted/60 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||
onClick={toggleChExpand}
|
||||
>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
@@ -1706,14 +1713,13 @@ function ChaptersPanel({
|
||||
</button>
|
||||
|
||||
{ch.status === 'parsing' && (
|
||||
<div className="px-3 py-1 border-b">
|
||||
<div className="px-3 py-1 border-t">
|
||||
<LogStream projectId={project.id} chapterId={ch.id} active={ch.status === 'parsing'} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Segments — flat list, each is its own card */}
|
||||
{chExpanded && chSegs.length > 0 && (
|
||||
<div className="px-3 py-2 space-y-2 border-b">
|
||||
<div className="px-3 py-2 space-y-2 border-t">
|
||||
{chSegs.map(seg => {
|
||||
const isEditing = editingSegId === seg.id
|
||||
const isRegenerating = regeneratingSegs.has(seg.id) || seg.status === 'generating'
|
||||
@@ -1874,6 +1880,7 @@ export default function Audiobook() {
|
||||
const [detail, setDetail] = useState<AudiobookProjectDetail | null>(null)
|
||||
const [segments, setSegments] = useState<AudiobookSegment[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
const [loadingAction, setLoadingAction] = useState(false)
|
||||
const [isPolling, setIsPolling] = useState(false)
|
||||
const [generatingChapterIndices, setGeneratingChapterIndices] = useState<Set<number>>(new Set())
|
||||
@@ -1930,8 +1937,8 @@ export default function Audiobook() {
|
||||
setIsPolling(false)
|
||||
autoExpandedRef.current.clear()
|
||||
prevStatusRef.current = ''
|
||||
fetchDetail()
|
||||
fetchSegments()
|
||||
setDetailLoading(true)
|
||||
Promise.all([fetchDetail(), fetchSegments()]).finally(() => setDetailLoading(false))
|
||||
}, [selectedProjectId, fetchDetail, fetchSegments])
|
||||
|
||||
const status = selectedProject?.status ?? ''
|
||||
@@ -2302,8 +2309,8 @@ export default function Audiobook() {
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
{(() => {
|
||||
const isNsfw = selectedProject.source_type === 'ai_generated' && !!(selectedProject.script_config as any)?.nsfw_mode
|
||||
const Icon = selectedProject.source_type === 'epub' ? BookOpen : isNsfw ? Flame : selectedProject.source_type === 'ai_generated' ? Wand2 : Book
|
||||
const cls = isNsfw ? 'h-4 w-4 shrink-0 text-orange-500' : selectedProject.source_type === 'ai_generated' ? 'h-4 w-4 shrink-0 text-violet-500' : 'h-4 w-4 shrink-0 text-muted-foreground'
|
||||
const Icon = selectedProject.source_type === 'epub' ? BookOpen : isNsfw ? NsfwIcon : selectedProject.source_type === 'ai_generated' ? Zap : Book
|
||||
const cls = isNsfw ? 'h-4 w-4 shrink-0 text-red-500' : 'h-4 w-4 shrink-0 text-muted-foreground'
|
||||
return <Icon className={cls} />
|
||||
})()}
|
||||
<span className="font-medium break-words min-w-0">{selectedProject.title}</span>
|
||||
@@ -2340,11 +2347,6 @@ export default function Audiobook() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{STEP_HINT_STATUSES.includes(status) && (
|
||||
<div className="shrink-0 mx-4 mt-2 text-xs text-muted-foreground bg-muted/50 rounded px-3 py-2 border-l-2 border-primary/40">
|
||||
{t(`stepHints.${status}`)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(status === 'analyzing' || (status === 'generating' && selectedProject.source_type === 'ai_generated')) && (
|
||||
<div className="shrink-0 mx-4 mt-2">
|
||||
@@ -2418,6 +2420,20 @@ export default function Audiobook() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailLoading ? (
|
||||
<div className="flex-1 flex overflow-hidden mt-2 px-2 pb-2 gap-2">
|
||||
<div className="w-56 shrink-0 flex flex-col gap-2">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-14 rounded-lg bg-muted/60 animate-pulse" style={{ animationDelay: `${i * 80}ms` }} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-12 rounded-lg bg-muted/60 animate-pulse" style={{ animationDelay: `${i * 60}ms` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex overflow-hidden mt-2 px-2 pb-2 gap-2">
|
||||
<CharactersPanel
|
||||
project={selectedProject}
|
||||
@@ -2453,6 +2469,7 @@ export default function Audiobook() {
|
||||
onScrollToChapterDone={() => setScrollToChapterId(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user