/ components / shared / TaggableToken.tsx
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  }