'use client'
import { Languages, LoaderCircleIcon, Trash2Icon } from 'lucide-react'
import { motion } from 'motion/react'
import { useTranslation } from 'react-i18next'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import { Button } from '@/components/ui/button'
import { DraftTextarea } from '@/components/ui/draft-textarea'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCurrentPage, useTextNodes, type TextNodeEntry } from '@/hooks/useCurrentPage'
import { getConfig, startPipeline, useGetCurrentLlm } from '@/lib/api/default/default'
import type { TextDataPatch } from '@/lib/api/schemas'
import { applyOp, queueAutoRender } from '@/lib/io/scene'
import { ops } from '@/lib/ops'
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
import { useJobsStore } from '@/lib/stores/jobsStore'
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
import { useSelectionStore } from '@/lib/stores/selectionStore'
export function TextBlocksPanel() {
const { t } = useTranslation()
const page = useCurrentPage()
const textNodes = useTextNodes()
const selectedIds = useSelectionStore((s) => s.nodeIds)
const select = useSelectionStore((s) => s.select)
const clearSelection = useSelectionStore((s) => s.clear)
const { data: llm } = useGetCurrentLlm()
const llmReady = llm?.status === 'ready'
const isProcessing = useJobsStore((s) =>
Object.values(s.jobs).some((j) => j.status === 'running'),
)
if (!page) {
return (
{t('textBlocks.emptyPrompt')}
)
}
const selectedIndex = textNodes.findIndex((n) => selectedIds.has(n.id))
const accordionValue = selectedIndex >= 0 ? selectedIndex.toString() : ''
const patchText = async (nodeId: string, patch: TextDataPatch) => {
await applyOp(
ops.updateNode(page.id, nodeId, {
data: { text: patch } as never,
}),
)
queueAutoRender(page.id)
}
const removeNode = async (nodeId: string) => {
const node = page.nodes[nodeId]
if (!node) return
const idx = Object.keys(page.nodes).indexOf(nodeId)
await applyOp(ops.removeNode(page.id, nodeId, node, idx < 0 ? 0 : idx))
clearSelection()
queueAutoRender(page.id)
}
const generate = async (nodeId: string) => {
if (!page) return
const cfg = await getConfig()
const translator = cfg.pipeline?.translator || 'llm'
const renderer = cfg.pipeline?.renderer || 'koharu-renderer'
const editor = useEditorUiStore.getState()
const prefs = usePreferencesStore.getState()
// Keep rendering page-scoped, but constrain translation to the clicked block.
await startPipeline({
steps: [translator, renderer],
pages: [page.id],
textNodeIds: [nodeId],
targetLanguage: editor.selectedLanguage,
systemPrompt: prefs.customSystemPrompt,
defaultFont: prefs.defaultFont,
})
}
return (
{t('textBlocks.title', { count: textNodes.length })}
{textNodes.length === 0 ? (
{t('textBlocks.none')}
) : (
{
if (!value) {
clearSelection()
return
}
const idx = Number(value)
const node = textNodes[idx]
if (node) select(node.id, false)
}}
className='flex flex-col gap-1'
>
{textNodes.map((node, index) => (
select(node.id, true)}
onPatch={(patch) => void patchText(node.id, patch)}
onDelete={() => void removeNode(node.id)}
onGenerate={() => void generate(node.id)}
processing={isProcessing}
llmReady={llmReady}
/>
))}
)}
)
}
type BlockCardProps = {
node: TextNodeEntry
index: number
selected: boolean
onToggleSelect: () => void
onPatch: (patch: TextDataPatch) => void
onDelete: () => void
onGenerate: () => void
processing: boolean
llmReady: boolean
}
function BlockCard({
node,
index,
selected,
onToggleSelect,
onPatch,
onDelete,
onGenerate,
processing,
llmReady,
}: BlockCardProps) {
const { t } = useTranslation()
const data = node.data
const hasOcr = !!data.text?.trim()
const hasTranslation = !!data.translation?.trim()
const preview = data.translation?.trim() || data.text?.trim()
return (
{
if (e.shiftKey || e.ctrlKey || e.metaKey) {
e.preventDefault()
e.stopPropagation()
onToggleSelect()
}
}}
className='flex w-full cursor-pointer items-center gap-1.5 px-2 py-1.5 text-left transition outline-none hover:no-underline data-[state=open]:bg-accent [&>svg]:hidden'
>
{index + 1}
{t('textBlocks.ocrBadge')}
{t('textBlocks.translationBadge')}
{preview && (
{preview}
)}
{t('textBlocks.ocrLabel')}
onPatch({ text: value })}
className='min-h-0 resize-none px-1.5 py-1 text-xs'
/>
{t('textBlocks.translationLabel')}
{t('workspace.deleteBlock')}
{t('llm.generateTooltip')}
onPatch({ translation: value })}
className='min-h-0 resize-none px-1.5 py-1 text-xs'
/>
)
}