|
|
|
|
@@ -409,7 +409,7 @@ function ProjectListSidebar({
|
|
|
|
|
<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 hover:bg-muted/60 ${
|
|
|
|
|
className={`w-full text-left rounded-lg border p-3 flex flex-col gap-1.5 transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 ${
|
|
|
|
|
selectedId === p.id
|
|
|
|
|
? 'bg-background border-border shadow-sm'
|
|
|
|
|
: 'bg-background/50 border-border/40'
|
|
|
|
|
@@ -541,7 +541,7 @@ function CharactersPanel({
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={ch.id}
|
|
|
|
|
className="w-full flex items-center gap-1.5 px-2 py-1.5 text-left hover:bg-muted/60 border-b border-border/30 transition-colors"
|
|
|
|
|
className="w-full flex items-center gap-1.5 px-2 py-1.5 text-left hover:bg-muted/60 border-b border-border/30 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
|
|
|
|
onClick={() => onScrollToChapter(ch.id)}
|
|
|
|
|
>
|
|
|
|
|
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${dotClass}`} />
|
|
|
|
|
@@ -562,7 +562,7 @@ function CharactersPanel({
|
|
|
|
|
</span>
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
{!isActive && status !== 'pending' && charCount > 0 && (
|
|
|
|
|
<Button size="sm" variant="ghost" className="h-6 text-xs px-2 text-muted-foreground" onClick={onAnalyze} disabled={loadingAction}>
|
|
|
|
|
<Button size="xs" variant="ghost" className="text-muted-foreground" onClick={onAnalyze} disabled={loadingAction}>
|
|
|
|
|
{t('projectCard.reanalyze')}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
@@ -737,7 +737,7 @@ function ChaptersPanel({
|
|
|
|
|
generatingChapterIndices: Set<number>
|
|
|
|
|
sequentialPlayingId: number | null
|
|
|
|
|
onParseChapter: (chapterId: number, title?: string) => void
|
|
|
|
|
onGenerate: (chapterIndex?: number) => void
|
|
|
|
|
onGenerate: (chapterIndex?: number, force?: boolean) => void
|
|
|
|
|
onParseAll: () => void
|
|
|
|
|
onGenerateAll: () => void
|
|
|
|
|
onProcessAll: () => void
|
|
|
|
|
@@ -758,6 +758,7 @@ function ChaptersPanel({
|
|
|
|
|
const [regeneratingSegs, setRegeneratingSegs] = useState<Set<number>>(new Set())
|
|
|
|
|
const [audioVersions, setAudioVersions] = useState<Record<number, number>>({})
|
|
|
|
|
const prevSegStatusRef = useRef<Record<number, string>>({})
|
|
|
|
|
const initialExpandDoneRef = useRef(false)
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!scrollToChapterId) return
|
|
|
|
|
@@ -823,12 +824,23 @@ function ChaptersPanel({
|
|
|
|
|
const generatingChapterIds = detail.chapters
|
|
|
|
|
.filter(ch => segments.some(s => s.chapter_index === ch.chapter_index && s.status === 'generating'))
|
|
|
|
|
.map(ch => ch.id)
|
|
|
|
|
if (generatingChapterIds.length === 0) return
|
|
|
|
|
setExpandedChapters(prev => {
|
|
|
|
|
const next = new Set(prev)
|
|
|
|
|
generatingChapterIds.forEach(id => next.add(id))
|
|
|
|
|
return next.size === prev.size ? prev : next
|
|
|
|
|
})
|
|
|
|
|
if (generatingChapterIds.length > 0) {
|
|
|
|
|
setExpandedChapters(prev => {
|
|
|
|
|
const next = new Set(prev)
|
|
|
|
|
generatingChapterIds.forEach(id => next.add(id))
|
|
|
|
|
return next.size === prev.size ? prev : next
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (!initialExpandDoneRef.current) {
|
|
|
|
|
initialExpandDoneRef.current = true
|
|
|
|
|
const chapterIdsWithSegs = detail.chapters
|
|
|
|
|
.filter(ch => segments.some(s => s.chapter_index === ch.chapter_index))
|
|
|
|
|
.map(ch => ch.id)
|
|
|
|
|
if (chapterIdsWithSegs.length > 0) {
|
|
|
|
|
setExpandedChapters(new Set(chapterIdsWithSegs))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [segments, detail])
|
|
|
|
|
|
|
|
|
|
const hasChapters = detail && detail.chapters.length > 0 && ['ready', 'generating', 'done'].includes(status)
|
|
|
|
|
@@ -842,17 +854,17 @@ function ChaptersPanel({
|
|
|
|
|
{hasChapters && (
|
|
|
|
|
<div className="flex items-center gap-1 flex-wrap">
|
|
|
|
|
{detail!.chapters.some(c => ['pending', 'error', 'ready'].includes(c.status)) && (
|
|
|
|
|
<Button size="sm" variant="outline" className="h-6 text-xs px-2" disabled={loadingAction} onClick={onParseAll}>
|
|
|
|
|
<Button size="xs" variant="outline" disabled={loadingAction} onClick={onParseAll}>
|
|
|
|
|
{t('projectCard.chapters.parseAll')}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
{detail!.chapters.some(c => c.status === 'ready') && (
|
|
|
|
|
<Button size="sm" variant="outline" className="h-6 text-xs px-2" disabled={loadingAction} onClick={onGenerateAll}>
|
|
|
|
|
<Button size="xs" variant="outline" disabled={loadingAction} onClick={onGenerateAll}>
|
|
|
|
|
{t('projectCard.chapters.generateAll')}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
{detail!.chapters.some(c => ['pending', 'error'].includes(c.status)) && detail!.chapters.some(c => c.status === 'ready') && (
|
|
|
|
|
<Button size="sm" className="h-6 text-xs px-2" disabled={loadingAction} onClick={onProcessAll}>
|
|
|
|
|
<Button size="xs" disabled={loadingAction} onClick={onProcessAll}>
|
|
|
|
|
{loadingAction ? <Loader2 className="h-3 w-3 animate-spin" /> : t('projectCard.chapters.processAll')}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
@@ -882,7 +894,7 @@ function ChaptersPanel({
|
|
|
|
|
<div key={ch.id} id={`ch-${ch.id}`}>
|
|
|
|
|
{/* Chapter header — flat, full-width, click to expand */}
|
|
|
|
|
<button
|
|
|
|
|
className="w-full flex items-center gap-2 px-3 py-2.5 bg-muted/40 hover:bg-muted/70 border-b text-left transition-colors"
|
|
|
|
|
className="w-full flex items-center gap-2 px-3 py-2.5 bg-muted/40 hover:bg-muted/70 border-b 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">
|
|
|
|
|
@@ -891,7 +903,7 @@ function ChaptersPanel({
|
|
|
|
|
<span className="text-xs font-medium flex-1 truncate">{chTitle}</span>
|
|
|
|
|
<span className="shrink-0 flex items-center gap-1.5" onClick={e => e.stopPropagation()}>
|
|
|
|
|
{ch.status === 'pending' && (
|
|
|
|
|
<Button size="sm" variant="outline" className="h-5 text-[11px] px-2" onClick={() => onParseChapter(ch.id, ch.title)}>
|
|
|
|
|
<Button size="xs" variant="outline" onClick={() => onParseChapter(ch.id, ch.title)}>
|
|
|
|
|
{t('projectCard.chapters.parse')}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
@@ -902,13 +914,13 @@ function ChaptersPanel({
|
|
|
|
|
)}
|
|
|
|
|
{ch.status === 'ready' && !chGenerating && !chAllDone && !generatingChapterIndices.has(ch.chapter_index) && (
|
|
|
|
|
<>
|
|
|
|
|
<Button size="sm" variant="outline" className="h-5 text-[11px] px-2" disabled={loadingAction} onClick={() => {
|
|
|
|
|
<Button size="xs" variant="outline" disabled={loadingAction} onClick={() => {
|
|
|
|
|
setExpandedChapters(prev => { const n = new Set(prev); n.add(ch.id); return n })
|
|
|
|
|
onGenerate(ch.chapter_index)
|
|
|
|
|
}}>
|
|
|
|
|
{t('projectCard.chapters.generate')}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button size="sm" variant="ghost" className="h-5 text-[11px] px-1.5 text-muted-foreground" disabled={loadingAction} onClick={() => onParseChapter(ch.id, ch.title)}>
|
|
|
|
|
<Button size="xs" variant="ghost" className="text-muted-foreground" disabled={loadingAction} onClick={() => onParseChapter(ch.id, ch.title)}>
|
|
|
|
|
{t('projectCard.chapters.reparse')}
|
|
|
|
|
</Button>
|
|
|
|
|
</>
|
|
|
|
|
@@ -918,16 +930,22 @@ function ChaptersPanel({
|
|
|
|
|
<Loader2 className="h-3 w-3 animate-spin" />{t('projectCard.chapters.segmentProgress', { done: chDone, total: chTotal })}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{ch.status === 'ready' && chAllDone && (
|
|
|
|
|
{(ch.status === 'done' || (ch.status === 'ready' && chAllDone)) && (
|
|
|
|
|
<>
|
|
|
|
|
<span className="text-[11px] text-muted-foreground">{t('projectCard.chapters.doneBadge', { count: chDone })}</span>
|
|
|
|
|
<Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={() => onDownload(ch.chapter_index)} title={t('projectCard.downloadAll')}>
|
|
|
|
|
<Button size="xs" variant="ghost" className="text-muted-foreground" disabled={loadingAction} onClick={() => {
|
|
|
|
|
setExpandedChapters(prev => { const n = new Set(prev); n.add(ch.id); return n })
|
|
|
|
|
onGenerate(ch.chapter_index, true)
|
|
|
|
|
}}>
|
|
|
|
|
<RefreshCw className="h-3 w-3" />{t('projectCard.chapters.generate')}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => onDownload(ch.chapter_index)} title={t('projectCard.downloadAll')}>
|
|
|
|
|
<Download className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
{ch.status === 'error' && (
|
|
|
|
|
<Button size="sm" variant="outline" className="h-5 text-[11px] px-2 text-destructive border-destructive/40" onClick={() => onParseChapter(ch.id, ch.title)}>
|
|
|
|
|
<Button size="xs" variant="outline" className="text-destructive border-destructive/40" onClick={() => onParseChapter(ch.id, ch.title)}>
|
|
|
|
|
{t('projectCard.chapters.reparse')}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
@@ -973,19 +991,19 @@ function ChaptersPanel({
|
|
|
|
|
<div className="ml-auto flex items-center gap-0.5 shrink-0">
|
|
|
|
|
{!isEditing ? (
|
|
|
|
|
<>
|
|
|
|
|
<Button size="icon" variant="ghost" className="h-5 w-5" onClick={() => startEdit(seg)} disabled={isRegenerating} title={t('projectCard.segments.edit')}>
|
|
|
|
|
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => startEdit(seg)} disabled={isRegenerating} title={t('projectCard.segments.edit')}>
|
|
|
|
|
<Pencil className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button size="icon" variant="ghost" className="h-5 w-5" onClick={() => handleRegenerate(seg.id)} disabled={isRegenerating} title={t('projectCard.segments.regenerate')}>
|
|
|
|
|
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => handleRegenerate(seg.id)} disabled={isRegenerating} title={t('projectCard.segments.regenerate')}>
|
|
|
|
|
<RefreshCw className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<Button size="icon" variant="ghost" className="h-5 w-5 text-destructive" onClick={cancelEdit} title={t('projectCard.segments.cancel')}>
|
|
|
|
|
<Button size="icon" variant="ghost" className="h-6 w-6 text-destructive" onClick={cancelEdit} title={t('projectCard.segments.cancel')}>
|
|
|
|
|
<X className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button size="icon" variant="ghost" className="h-5 w-5 text-primary" onClick={() => saveEdit(seg.id)} disabled={isSaving} title={t('projectCard.segments.save')}>
|
|
|
|
|
<Button size="icon" variant="ghost" className="h-6 w-6 text-primary" onClick={() => saveEdit(seg.id)} disabled={isSaving} title={t('projectCard.segments.save')}>
|
|
|
|
|
{isSaving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
|
|
|
|
</Button>
|
|
|
|
|
</>
|
|
|
|
|
@@ -1179,7 +1197,7 @@ export default function Audiobook() {
|
|
|
|
|
}
|
|
|
|
|
}, [segments, generatingChapterIndices])
|
|
|
|
|
|
|
|
|
|
const shouldPoll = isPolling || ['analyzing', 'generating'].includes(status) || hasParsingChapter || generatingChapterIndices.size > 0
|
|
|
|
|
const shouldPoll = isPolling || ['analyzing', 'generating'].includes(status) || hasParsingChapter || generatingChapterIndices.size > 0 || segments.some(s => s.status === 'generating')
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!shouldPoll || !selectedProjectId) return
|
|
|
|
|
@@ -1235,7 +1253,7 @@ export default function Audiobook() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleGenerate = async (chapterIndex?: number) => {
|
|
|
|
|
const handleGenerate = async (chapterIndex?: number, force?: boolean) => {
|
|
|
|
|
if (!selectedProject) return
|
|
|
|
|
setLoadingAction(true)
|
|
|
|
|
if (chapterIndex !== undefined) {
|
|
|
|
|
@@ -1244,7 +1262,7 @@ export default function Audiobook() {
|
|
|
|
|
setIsPolling(true)
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
await audiobookApi.generate(selectedProject.id, chapterIndex)
|
|
|
|
|
await audiobookApi.generate(selectedProject.id, chapterIndex, force)
|
|
|
|
|
toast.success(chapterIndex !== undefined
|
|
|
|
|
? t('projectCard.chapters.generateStarted', { index: chapterIndex + 1 })
|
|
|
|
|
: t('projectCard.chapters.generateAllStarted'))
|
|
|
|
|
@@ -1365,7 +1383,6 @@ export default function Audiobook() {
|
|
|
|
|
if (!selectedProject) return
|
|
|
|
|
try {
|
|
|
|
|
await audiobookApi.regenerateSegment(selectedProject.id, segmentId)
|
|
|
|
|
setIsPolling(true)
|
|
|
|
|
fetchSegments()
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
toast.error(formatApiError(e))
|
|
|
|
|
@@ -1479,22 +1496,22 @@ export default function Audiobook() {
|
|
|
|
|
{t(`status.${displayStatus}`, { defaultValue: displayStatus })}
|
|
|
|
|
</Badge>
|
|
|
|
|
{status === 'pending' && (
|
|
|
|
|
<Button size="sm" className="h-7 text-xs px-2" onClick={handleAnalyze} disabled={loadingAction}>
|
|
|
|
|
<Button size="sm" onClick={handleAnalyze} disabled={loadingAction}>
|
|
|
|
|
{t('projectCard.analyze')}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
{status === 'ready' && (
|
|
|
|
|
<Button size="sm" className="h-7 text-xs px-2" onClick={handleProcessAll} disabled={loadingAction}>
|
|
|
|
|
{loadingAction ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
|
|
|
|
|
<Button size="sm" onClick={handleProcessAll} disabled={loadingAction}>
|
|
|
|
|
{loadingAction ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
|
|
|
|
|
{t('projectCard.processAll')}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
{status === 'done' && (
|
|
|
|
|
<Button size="sm" variant="outline" className="h-7 text-xs px-2" onClick={() => handleDownload()} disabled={loadingAction}>
|
|
|
|
|
<Download className="h-3 w-3 mr-1" />{t('projectCard.downloadAll')}
|
|
|
|
|
<Button size="sm" variant="outline" onClick={() => handleDownload()} disabled={loadingAction}>
|
|
|
|
|
<Download className="h-3 w-3" />{t('projectCard.downloadAll')}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
<Button size="icon" variant="ghost" className="h-7 w-7 shrink-0" onClick={handleDelete}>
|
|
|
|
|
<Button size="icon" variant="ghost" className="h-8 w-8 shrink-0" onClick={handleDelete}>
|
|
|
|
|
<Trash2 className="h-4 w-4 text-destructive" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
@@ -1532,14 +1549,14 @@ export default function Audiobook() {
|
|
|
|
|
{chaptersError > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
<span className="text-destructive">({t('projectCard.chaptersError', { count: chaptersError })})</span>
|
|
|
|
|
<Button size="sm" variant="outline" className="h-5 text-[10px] px-1.5 text-destructive border-destructive/40" onClick={handleRetryFailed}>
|
|
|
|
|
<Button size="xs" variant="outline" className="text-destructive border-destructive/40" onClick={handleRetryFailed}>
|
|
|
|
|
{t('projectCard.retryFailed')}
|
|
|
|
|
</Button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{chaptersParsing > 0 && totalCount > 0 && (
|
|
|
|
|
<Button size="sm" variant="ghost" className="h-5 text-[10px] px-1.5 text-destructive" onClick={handleCancelBatch}>
|
|
|
|
|
<Button size="xs" variant="ghost" className="text-destructive" onClick={handleCancelBatch}>
|
|
|
|
|
{t('projectCard.cancelParsing')}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
@@ -1555,7 +1572,7 @@ export default function Audiobook() {
|
|
|
|
|
<span>{t('projectCard.segmentsProgress', { done: doneCount, total: totalCount })}</span>
|
|
|
|
|
</div>
|
|
|
|
|
{!chaptersParsing && hasGenerating && (
|
|
|
|
|
<Button size="sm" variant="ghost" className="h-5 text-[10px] px-1.5 text-destructive" onClick={handleCancelBatch}>
|
|
|
|
|
<Button size="xs" variant="ghost" className="text-destructive" onClick={handleCancelBatch}>
|
|
|
|
|
{t('projectCard.cancelGenerating')}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
@@ -1565,7 +1582,7 @@ export default function Audiobook() {
|
|
|
|
|
)}
|
|
|
|
|
{chaptersParsing > 0 && !totalCount && (
|
|
|
|
|
<div className="flex justify-end">
|
|
|
|
|
<Button size="sm" variant="ghost" className="h-5 text-[10px] px-1.5 text-destructive" onClick={handleCancelBatch}>
|
|
|
|
|
<Button size="xs" variant="ghost" className="text-destructive" onClick={handleCancelBatch}>
|
|
|
|
|
{t('projectCard.cancelParsing')}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
@@ -1586,6 +1603,7 @@ export default function Audiobook() {
|
|
|
|
|
onScrollToChapter={(id) => setScrollToChapterId(id)}
|
|
|
|
|
/>
|
|
|
|
|
<ChaptersPanel
|
|
|
|
|
key={selectedProject.id}
|
|
|
|
|
project={selectedProject}
|
|
|
|
|
detail={detail}
|
|
|
|
|
segments={segments}
|
|
|
|
|
|