"use client"; import { useState, useCallback, useRef } from "react"; //const API_BASE = ""; const API_BASE = ""; export interface Entity { word: string; entity_group: string; score: number; start?: number | null; end?: number | null; } export interface DocForEditor { doc_id: number; text: string; entities: Entity[]; sentiment: { label: string; score: number } | null; } interface Props { doc: DocForEditor; onClose: () => void; onSaved?: (updated: DocForEditor) => void; } const ENTITY_TYPES = ["PER", "ORG", "LOC", "MISC"]; const ENTITY_COLORS: Record = { PER: "#ff6b6b", ORG: "#4ecdc4", LOC: "#ffd93d", MISC: "#a78bfa", }; const SENTIMENT_LABELS = ["positive", "neutral", "negative"] as const; const SENTIMENT_MN: Record = { positive: "Эерэг", neutral: "Саармаг", negative: "Сөрөг", }; function color(group: string) { return ENTITY_COLORS[group] || "#6c63ff"; } // Split text into plain/entity segments using char offsets type Segment = | { kind: "text"; text: string } | { kind: "entity"; text: string; index: number; entity: Entity }; function buildSegments(text: string, entities: Entity[]): Segment[] { // Only use entities that have valid start/end offsets const valid = entities .map((e, index) => ({ e, index })) .filter(({ e }) => e.start != null && e.end != null && e.start >= 0 && e.end > e.start) .sort((a, b) => (a.e.start ?? 0) - (b.e.start ?? 0)); if (valid.length === 0) return [{ kind: "text", text }]; const segments: Segment[] = []; let cursor = 0; for (const { e, index } of valid) { const start = e.start ?? 0; const end = e.end ?? 0; if (start < cursor) continue; // overlapping span — skip if (start > cursor) { segments.push({ kind: "text", text: text.slice(cursor, start) }); } segments.push({ kind: "entity", text: text.slice(start, end), index, entity: e }); cursor = end; } if (cursor < text.length) { segments.push({ kind: "text", text: text.slice(cursor) }); } return segments; } export default function AnnotationEditor({ doc, onClose, onSaved }: Props) { const [entities, setEntities] = useState(doc.entities); const [sentimentLabel, setSentimentLabel] = useState( doc.sentiment?.label ?? "neutral" ); const [sentimentScore, setSentimentScore] = useState( doc.sentiment?.score ?? 1.0 ); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); const [error, setError] = useState(""); // Popover state for entity actions const [activeIdx, setActiveIdx] = useState(null); const popoverRef = useRef(null); // New entity from text selection const [newEntityPopover, setNewEntityPopover] = useState<{ selection: string; start: number; end: number; } | null>(null); const [newEntityGroup, setNewEntityGroup] = useState("PER"); const textRef = useRef(null); const handleEntityClick = (idx: number) => { setActiveIdx(activeIdx === idx ? null : idx); setNewEntityPopover(null); }; const changeLabel = (idx: number, group: string) => { setEntities((prev) => prev.map((e, i) => (i === idx ? { ...e, entity_group: group } : e)) ); setActiveIdx(null); }; const removeEntity = (idx: number) => { setEntities((prev) => prev.filter((_, i) => i !== idx)); setActiveIdx(null); }; const handleTextMouseUp = useCallback(() => { const sel = window.getSelection(); if (!sel || sel.isCollapsed || !textRef.current) return; const range = sel.getRangeAt(0); // Walk the text container to compute char offset const container = textRef.current; let start = 0; let end = 0; let charCount = 0; let foundStart = false; function walk(node: Node) { if (node.nodeType === Node.TEXT_NODE) { const len = node.textContent?.length ?? 0; if (!foundStart && node === range.startContainer) { start = charCount + range.startOffset; foundStart = true; } if (node === range.endContainer) { end = charCount + range.endOffset; } charCount += len; } else { node.childNodes.forEach(walk); } } walk(container); const selectedText = sel.toString().trim(); if (!selectedText || start === end) return; sel.removeAllRanges(); setNewEntityPopover({ selection: selectedText, start, end }); setActiveIdx(null); }, []); const addNewEntity = () => { if (!newEntityPopover) return; setEntities((prev) => [ ...prev, { word: newEntityPopover.selection, entity_group: newEntityGroup, score: 1.0, start: newEntityPopover.start, end: newEntityPopover.end, }, ]); setNewEntityPopover(null); }; const save = async () => { setSaving(true); setError(""); try { const res = await fetch(`${API_BASE}/api/documents/${doc.doc_id}`, { method: "PATCH", headers: { "ngrok-skip-browser-warning": "true", "Content-Type": "application/json" }, body: JSON.stringify({ entities, sentiment_label: sentimentLabel, sentiment_score: sentimentScore, }), }); if (!res.ok) { const err = await res.json(); throw new Error(err.detail || "Save failed"); } setSaved(true); setTimeout(() => setSaved(false), 2500); onSaved?.({ ...doc, entities, sentiment: { label: sentimentLabel, score: sentimentScore }, }); } catch (e: any) { setError(e.message); } finally { setSaving(false); } }; const segments = buildSegments(doc.text, entities); const hasOffsets = entities.some((e) => e.start != null && e.end != null); return (
{ if (e.target === e.currentTarget) onClose(); }} >
{/* Header */}

Тэмдэглэл засах — Doc #{doc.doc_id}

{/* Sentiment selector */}
Сэтгэгдэл: {SENTIMENT_LABELS.map((lbl) => ( ))}
{/* Entity legend */}
{ENTITY_TYPES.map((t) => ( {t} ))} — текст сонгоход шинэ нэрлэсэн объект нэмнэ
{/* Annotated text */}
{hasOffsets ? ( segments.map((seg, si) => seg.kind === "text" ? ( {seg.text} ) : ( handleEntityClick(seg.index)} style={{ background: `${color(seg.entity.entity_group)}25`, borderBottom: `2.5px solid ${color(seg.entity.entity_group)}`, borderRadius: "3px 3px 0 0", padding: "0 2px", cursor: "pointer", color: "inherit", }} > {seg.text} [{seg.entity.entity_group}] {/* Entity action popover */} {activeIdx === seg.index && ( Шошго өөрчлөх: {ENTITY_TYPES.map((t) => ( ))} )} ) ) ) : ( /* Fallback: no offsets — show raw text + entity list below */ {doc.text} )}
{/* New entity popover from selection */} {newEntityPopover && (
Нэмэх: “{newEntityPopover.selection}”
)} {/* Entity list (always shown as summary) */} {!hasOffsets && entities.length > 0 && (

Нэрлэсэн объектууд (засахын тулд текст дээр дарна уу):

{entities.map((e, i) => ( removeEntity(i)} title="Устгах" style={{ padding: "0.25rem 0.6rem", borderRadius: 6, cursor: "pointer", background: `${color(e.entity_group)}20`, border: `1px solid ${color(e.entity_group)}`, color: color(e.entity_group), fontSize: "0.8rem", display: "flex", alignItems: "center", gap: 4, }} > {e.word} [{e.entity_group}] ))}
)} {/* Error */} {error &&

❌ {error}

} {/* Actions */}
{saved && ✓ Хадгалагдлаа}
); }