TaggableToken.tsx
1 'use client'; 2 3 import { useState, useRef, useEffect, useCallback } from 'react'; 4 import { useAuth } from '@/lib/auth/context'; 5 import { useTranslation } from '@/lib/i18n'; 6 import type { OutputTagType, OutputTag } from '@/lib/types'; 7 8 const TAG_META: { value: OutputTagType; labelKey: string; color: string; icon: string }[] = [ 9 { value: 'accurate', labelKey: 'tags.accurate', color: 'bg-success/10 text-success border-success/20', icon: '\u2713' }, 10 { value: 'inaccurate', labelKey: 'tags.inaccurate', color: 'bg-danger/10 text-danger border-danger/20', icon: '\u2717' }, 11 { value: 'irrelevant', labelKey: 'tags.irrelevant', color: 'bg-amber-400/10 text-amber-500 border-amber-400/20', icon: '\u2212' }, 12 { value: 'missing_context', labelKey: 'tags.missingContext', color: 'bg-blue-400/10 text-blue-500 border-blue-400/20', icon: '?' }, 13 { value: 'too_generic', labelKey: 'tags.tooGeneric', color: 'bg-purple-400/10 text-purple-500 border-purple-400/20', icon: '\u2026' }, 14 ]; 15 16 interface TaggableTokenProps { 17 children: React.ReactNode; 18 analysisId: string | undefined; 19 section: string; 20 elementKey?: string; 21 elementIndex?: number; 22 existingTags?: OutputTag[]; 23 onTagCreated?: (tag: OutputTag) => void; 24 onTagDeleted?: (tagId: string) => void; 25 /** Inline or block display mode */ 26 inline?: boolean; 27 } 28 29 export default function TaggableToken({ 30 children, 31 analysisId, 32 section, 33 elementKey, 34 elementIndex, 35 existingTags = [], 36 onTagCreated, 37 onTagDeleted, 38 inline = false, 39 }: TaggableTokenProps) { 40 const { session } = useAuth(); 41 const { t } = useTranslation(); 42 const [showPopover, setShowPopover] = useState(false); 43 const [comment, setComment] = useState(''); 44 const [saving, setSaving] = useState(false); 45 const [deleting, setDeleting] = useState<string | null>(null); 46 const popoverRef = useRef<HTMLDivElement>(null); 47 const containerRef = useRef<HTMLDivElement>(null); 48 49 // Close popover on outside click 50 useEffect(() => { 51 if (!showPopover) return; 52 const handler = (e: MouseEvent) => { 53 if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { 54 setShowPopover(false); 55 setComment(''); 56 } 57 }; 58 document.addEventListener('mousedown', handler); 59 return () => document.removeEventListener('mousedown', handler); 60 }, [showPopover]); 61 62 const handleTag = useCallback(async (tagType: OutputTagType) => { 63 if (!session?.access_token || !analysisId) return; 64 setSaving(true); 65 66 try { 67 // Get plain text from children for taggedText 68 const text = containerRef.current?.textContent?.slice(0, 1000) || ''; 69 70 const res = await fetch('/api/tags', { 71 method: 'POST', 72 headers: { 73 'Content-Type': 'application/json', 74 Authorization: `Bearer ${session.access_token}`, 75 }, 76 body: JSON.stringify({ 77 analysisId, 78 section, 79 elementKey: elementKey || undefined, 80 elementIndex: elementIndex ?? undefined, 81 taggedText: text || undefined, 82 tag: tagType, 83 comment: comment.trim() || undefined, 84 }), 85 }); 86 87 if (res.ok) { 88 const data = await res.json(); 89 onTagCreated?.(data.tag); 90 setShowPopover(false); 91 setComment(''); 92 } 93 } catch { 94 // Non-critical 95 } finally { 96 setSaving(false); 97 } 98 }, [session?.access_token, analysisId, section, elementKey, elementIndex, comment, onTagCreated]); 99 100 const handleDelete = useCallback(async (tagId: string) => { 101 if (!session?.access_token) return; 102 setDeleting(tagId); 103 104 try { 105 const res = await fetch(`/api/tags?id=${tagId}`, { 106 method: 'DELETE', 107 headers: { Authorization: `Bearer ${session.access_token}` }, 108 }); 109 if (res.ok) { 110 onTagDeleted?.(tagId); 111 } 112 } catch { 113 // Non-critical 114 } finally { 115 setDeleting(null); 116 } 117 }, [session?.access_token, onTagDeleted]); 118 119 // Don't render tag UI if no analysisId or not logged in 120 if (!analysisId || !session) { 121 return <>{children}</>; 122 } 123 124 const Tag = inline ? 'span' : 'div'; 125 const myTags = existingTags.filter( 126 t => t.section === section 127 && t.elementKey === (elementKey || null) 128 && (elementIndex == null || t.elementIndex === elementIndex) 129 ); 130 131 return ( 132 <Tag ref={containerRef} className={`group/tag relative ${inline ? 'inline' : ''}`}> 133 {children} 134 135 {/* Existing tags badges */} 136 {myTags.length > 0 && ( 137 <span className={`${inline ? 'inline-flex ml-1.5' : 'flex mt-1'} flex-wrap gap-1`}> 138 {myTags.map(tag => { 139 const opt = TAG_META.find(o => o.value === tag.tag); 140 return ( 141 <span 142 key={tag.id} 143 className={`inline-flex items-center gap-0.5 text-[10px] font-medium px-1.5 py-0.5 rounded border ${opt?.color || 'bg-black/[0.04] text-text-tertiary border-black/[0.06]'}`} 144 title={tag.comment || undefined} 145 > 146 {opt?.icon} {opt ? t(opt.labelKey) : tag.tag} 147 <button 148 onClick={(e) => { e.stopPropagation(); handleDelete(tag.id); }} 149 disabled={deleting === tag.id} 150 className="ml-0.5 opacity-50 hover:opacity-100 transition-opacity" 151 > 152 <svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3"> 153 <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/> 154 </svg> 155 </button> 156 </span> 157 ); 158 })} 159 </span> 160 )} 161 162 {/* Tag trigger button — visible on hover */} 163 <button 164 onClick={(e) => { e.stopPropagation(); setShowPopover(!showPopover); }} 165 className={`${inline ? 'inline-flex ml-1' : 'absolute -right-1 -top-1'} items-center justify-center w-5 h-5 rounded-md bg-black/[0.04] hover:bg-primary/10 text-text-tertiary hover:text-primary transition-all opacity-0 group-hover/tag:opacity-100 focus:opacity-100`} 166 title={t('tags.tagThis')} 167 > 168 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> 169 <path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/> 170 <line x1="7" y1="7" x2="7.01" y2="7"/> 171 </svg> 172 </button> 173 174 {/* Popover */} 175 {showPopover && ( 176 <div 177 ref={popoverRef} 178 className="absolute z-50 top-full left-0 mt-1 bg-white border border-black/[0.08] rounded-xl shadow-lg p-3 min-w-[220px]" 179 onClick={(e) => e.stopPropagation()} 180 > 181 <p className="text-[11px] font-medium text-text-tertiary uppercase tracking-wider mb-2"> 182 {t('tags.rateOutput')} 183 </p> 184 <div className="flex flex-wrap gap-1.5 mb-2"> 185 {TAG_META.map(opt => ( 186 <button 187 key={opt.value} 188 onClick={() => handleTag(opt.value)} 189 disabled={saving} 190 className={`text-[11px] font-medium px-2 py-1 rounded-md border transition-colors hover:opacity-80 disabled:opacity-50 ${opt.color}`} 191 > 192 {opt.icon} {t(opt.labelKey)} 193 </button> 194 ))} 195 </div> 196 <textarea 197 value={comment} 198 onChange={(e) => setComment(e.target.value)} 199 placeholder={t('tags.commentPlaceholder')} 200 className="w-full h-14 text-xs bg-black/[0.02] border border-black/[0.06] rounded-lg px-2.5 py-2 resize-none placeholder:text-text-tertiary/60 focus:outline-none focus:border-primary/30" 201 maxLength={500} 202 /> 203 </div> 204 )} 205 </Tag> 206 ); 207 }