| 'use client' |
|
|
| import { useState } from 'react' |
| import type { AnnotateResponse, AnnotationEntry } from '@/lib/api' |
| import type { Settings } from '@/types' |
|
|
| interface Sentence { |
| text: string |
| annotations: AnnotationEntry[] |
| startAnnotIdx: number |
| } |
|
|
| const CHARS_PER_PAGE = 2000 |
|
|
| interface Props { |
| result: AnnotateResponse |
| settings: Settings |
| } |
|
|
| export default function AnnotationResult({ result, settings }: Props) { |
| const [copied, setCopied] = useState<'annotated' | 'replaced' | null>(null) |
| const [toggled, setToggled] = useState<Set<number>>(new Set()) |
| const [page, setPage] = useState(0) |
|
|
| if (!result.valid || !result.annotation) { |
| return ( |
| <div className="bg-red-50 border border-red-200 rounded-2xl p-5 text-sm text-red-700"> |
| <span className="font-semibold">Annotation failed: </span> |
| {result.reason ?? 'Unknown error.'} |
| </div> |
| ) |
| } |
|
|
| const { marked, annotations } = result.annotation |
| const sentences = parseSentences(marked, annotations) |
| const pages = groupByChars(sentences, CHARS_PER_PAGE) |
| const totalPages = Math.max(1, pages.length) |
| const safePage = Math.min(page, totalPages - 1) |
| const pageSentences = pages[safePage] ?? [] |
|
|
| const lineHeight = |
| settings.displayMode === 'top' |
| ? `${settings.emojiSize + 44}px` |
| : `${Math.max(32, settings.emojiSize * 1.4)}px` |
|
|
| function handleCopy(type: 'annotated' | 'replaced') { |
| const text = |
| type === 'annotated' |
| ? buildAnnotatedText(marked, annotations) |
| : buildReplacedText(marked, annotations) |
| navigator.clipboard.writeText(text).then(() => { |
| setCopied(type) |
| setTimeout(() => setCopied(null), 2000) |
| }) |
| } |
|
|
| function handleToggle(globalIdx: number) { |
| setToggled((prev) => { |
| const next = new Set(prev) |
| if (next.has(globalIdx)) next.delete(globalIdx) |
| else next.add(globalIdx) |
| return next |
| }) |
| } |
|
|
| return ( |
| <div className="bg-white/80 dark:bg-black/40 backdrop-blur-md dark:backdrop-blur-xl border border-gray-300 dark:border-white/10 rounded-2xl shadow-md p-6"> |
| {/* Header */} |
| <div className="flex items-center justify-between pb-4 border-b border-gray-300 dark:border-gray-600"> |
| <p className="text-sm font-bold text-gray-600 dark:text-white uppercase tracking-widest"> |
| Result |
| </p> |
| <div className="flex items-center gap-3"> |
| <CopyButton label="Copy annotated" done={copied === 'annotated'} onClick={() => handleCopy('annotated')} /> |
| <CopyButton label="Copy replaced" done={copied === 'replaced'} onClick={() => handleCopy('replaced')} /> |
| </div> |
| </div> |
| |
| {/* Text */} |
| <p className="text-[16px] mt-4" style={{ lineHeight }}> |
| {pageSentences.map((sentence, si) => ( |
| <span key={si}> |
| {settings.displayMode === 'top' |
| ? renderTop(sentence, settings.emojiSize, si) |
| : renderToggle(sentence, settings.emojiSize, toggled, handleToggle, si)} |
| {si < pageSentences.length - 1 ? ' ' : ''} |
| </span> |
| ))} |
| </p> |
| |
| {/* Pagination — always visible */} |
| <div className="flex items-center justify-end gap-4 mt-5 pt-4 border-t border-gray-300 dark:border-gray-600"> |
| <button |
| onClick={() => setPage((p) => Math.max(0, p - 1))} |
| disabled={safePage === 0} |
| className="px-3 py-1.5 text-[15px] font-semibold text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" |
| > |
| ← Prev |
| </button> |
| <span className="text-[15px] font-semibold text-gray-600 dark:text-gray-400 tabular-nums"> |
| {safePage + 1} / {totalPages} |
| </span> |
| <button |
| onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))} |
| disabled={safePage === totalPages - 1} |
| className="px-3 py-1.5 text-[15px] font-semibold text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" |
| > |
| Next → |
| </button> |
| </div> |
| </div> |
| ) |
| } |
|
|
|
|
| function groupByChars(sentences: Sentence[], maxChars: number): Sentence[][] { |
| const pages: Sentence[][] = [] |
| let current: Sentence[] = [] |
| let count = 0 |
| for (const s of sentences) { |
| const len = s.text.replace(/<\/?span>/g, '').length |
| if (current.length > 0 && count + len > maxChars) { |
| pages.push(current) |
| current = [s] |
| count = len |
| } else { |
| current.push(s) |
| count += len |
| } |
| } |
| if (current.length > 0) pages.push(current) |
| return pages |
| } |
|
|
| function parseSentences(marked: string, annotations: AnnotationEntry[]): Sentence[] { |
| const parts = marked.split(/(?<=[.!?])\s+/) |
| let globalAnnotIdx = 0 |
| return parts.map((text) => { |
| const spanCount = (text.match(/<span>/g) ?? []).length |
| const sentence: Sentence = { |
| text: text.trim(), |
| annotations: annotations.slice(globalAnnotIdx, globalAnnotIdx + spanCount), |
| startAnnotIdx: globalAnnotIdx, |
| } |
| globalAnnotIdx += spanCount |
| return sentence |
| }) |
| } |
|
|
| function renderTop( |
| sentence: Sentence, |
| emojiSize: number, |
| prefix: number |
| ): React.ReactNode[] { |
| const parts = sentence.text.split(/(<span>.*?<\/span>)/g) |
| let localIdx = 0 |
| return parts.map((part, i) => { |
| const match = part.match(/^<span>(.*?)<\/span>$/) |
| if (match) { |
| const word = match[1] |
| const emoji = sentence.annotations[localIdx++]?.emojis?.trim() ?? '' |
| |
| if (!emoji) return <span key={`${prefix}-${i}`}>{word}</span> |
| return ( |
| <span |
| key={`${prefix}-${i}`} |
| className="inline-block mx-0.5 text-center" |
| style={{ verticalAlign: 'bottom' }} |
| > |
| <span className="block" style={{ fontSize: emojiSize, lineHeight: 1, marginBottom: -14 }}> |
| {emoji} |
| </span> |
| <span className="bg-yellow-100 dark:bg-yellow-700/50 rounded-sm px-0.5"> |
| {word} |
| </span> |
| </span> |
| ) |
| } |
| return <span key={`${prefix}-${i}`}>{part}</span> |
| }) |
| } |
|
|
| function renderToggle( |
| sentence: Sentence, |
| emojiSize: number, |
| toggled: Set<number>, |
| onToggle: (globalIdx: number) => void, |
| prefix: number |
| ): React.ReactNode[] { |
| const parts = sentence.text.split(/(<span>.*?<\/span>)/g) |
| let localIdx = 0 |
| return parts.map((part, i) => { |
| const match = part.match(/^<span>(.*?)<\/span>$/) |
| if (match) { |
| const word = match[1] |
| const globalIdx = sentence.startAnnotIdx + localIdx |
| const emoji = sentence.annotations[localIdx++]?.emojis?.trim() ?? '' |
| |
| if (!emoji) return <span key={`${prefix}-${i}`}>{word}</span> |
| const isToggled = toggled.has(globalIdx) |
| return ( |
| <button |
| key={`${prefix}-${i}`} |
| onClick={() => onToggle(globalIdx)} |
| title={isToggled ? word : emoji} |
| className="inline-block mx-0.5 bg-yellow-100 dark:bg-yellow-700/50 rounded-sm px-0.5 cursor-pointer align-baseline hover:opacity-70 transition-opacity" |
| > |
| {isToggled ? ( |
| <span style={{ fontSize: emojiSize }}>{emoji}</span> |
| ) : ( |
| word |
| )} |
| </button> |
| ) |
| } |
| return <span key={`${prefix}-${i}`}>{part}</span> |
| }) |
| } |
|
|
| function buildAnnotatedText(marked: string, annotations: AnnotationEntry[]): string { |
| const parts = marked.split(/(<span>.*?<\/span>)/g) |
| let i = 0 |
| return parts |
| .map((part) => { |
| const match = part.match(/^<span>(.*?)<\/span>$/) |
| if (match) { |
| const emoji = annotations[i++]?.emojis.trim() ?? '' |
| |
| return emoji ? `[${match[1]}](${emoji})` : match[1] |
| } |
| return part |
| }) |
| .join('') |
| } |
|
|
| function buildReplacedText(marked: string, annotations: AnnotationEntry[]): string { |
| const parts = marked.split(/(<span>.*?<\/span>)/g) |
| let i = 0 |
| return parts |
| .map((part) => { |
| const match = part.match(/^<span>(.*?)<\/span>$/) |
| if (match) { |
| const emoji = annotations[i++]?.emojis.trim() ?? '' |
| |
| return emoji ? emoji : match[1] |
| } |
| return part |
| }) |
| .join('') |
| } |
|
|
| function CopyButton({ label, done, onClick }: { label: string; done: boolean; onClick: () => void }) { |
| return ( |
| <button |
| onClick={onClick} |
| title={label} |
| className="flex items-center gap-1.5 text-[13px] font-semibold text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors" |
| > |
| {done ? <CheckIcon /> : <CopyIcon />} |
| <span>{done ? 'Copied!' : label}</span> |
| </button> |
| ) |
| } |
|
|
| function CopyIcon() { |
| return ( |
| <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" |
| fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> |
| <rect width="14" height="14" x="8" y="8" rx="2" ry="2" /> |
| <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /> |
| </svg> |
| ) |
| } |
|
|
| function CheckIcon() { |
| return ( |
| <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" |
| fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> |
| <polyline points="20 6 9 17 4 12" /> |
| </svg> |
| ) |
| } |
|
|