emojinize / frontend /src /components /AnnotationForm.tsx
Luturtun's picture
Replace models: new encoder + gemma-3-1b/qwen3 decoders, drop llama3
34cc2e8
Raw
History Blame Contribute Delete
6.68 kB
'use client'
import { useState, useEffect, useRef } from 'react'
import type { Encoder, Decoder } from '@/lib/api'
const ENCODERS: { value: Encoder; label: string }[] = [
{ value: 'xlm-roberta-large', label: 'xlm-roberta-large' },
]
const DECODERS: { value: Decoder; label: string }[] = [
{ value: 'gemma3', label: 'gemma-3-1b-it' },
{ value: 'qwen3', label: 'Qwen3-4B-Instruct-2507' },
]
const MAX_CHARS = 10000
const LOADING_MESSAGES = [
'🏷️✍️ Annotating...',
'⏳⚙️ Processing...',
'🧠🤔💭 Thinking...',
'🔍📊 Analyzing...',
'🔤➡️😄 Emojinizing...',
]
interface Props {
onSubmit: (encoder: Encoder, decoder: Decoder, text: string) => void
onStop: () => void
loading: boolean
}
export default function AnnotationForm({ onSubmit, onStop, loading }: Props) {
const [encoder, setEncoder] = useState<Encoder>('xlm-roberta-large')
const [decoder, setDecoder] = useState<Decoder>('gemma3')
const [text, setText] = useState('')
const [msgIdx, setMsgIdx] = useState(0)
useEffect(() => {
if (!loading) return
setMsgIdx(0)
const id = setInterval(
() => setMsgIdx((i) => (i + 1) % LOADING_MESSAGES.length),
3000
)
return () => clearInterval(id)
}, [loading])
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!text.trim()) return
onSubmit(encoder, decoder, text)
}
return (
<form onSubmit={handleSubmit} 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 space-y-5">
<div className="flex gap-4">
<div className="flex-1">
<label className="block text-sm font-bold text-gray-600 dark:text-white uppercase tracking-widest mb-2">
Encoder
</label>
<Dropdown
options={ENCODERS}
value={encoder}
onChange={(v) => setEncoder(v as Encoder)}
/>
</div>
<div className="flex-1">
<label className="block text-sm font-bold text-gray-600 dark:text-white uppercase tracking-widest mb-2">
Decoder
</label>
<Dropdown
options={DECODERS}
value={decoder}
onChange={(v) => setDecoder(v as Decoder)}
/>
</div>
</div>
<div>
<label className="block text-sm font-bold text-gray-600 dark:text-white uppercase tracking-widest mb-2">
Text
</label>
<textarea
value={text}
onChange={(e) => setText(e.target.value.slice(0, MAX_CHARS))}
rows={5}
placeholder="Enter text to annotate…"
className="w-full bg-gray-50 dark:bg-gray-700/60 border border-gray-300 dark:border-gray-600 rounded-xl px-4 py-3 text-[15px] text-gray-800 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 resize-y min-h-[7rem] focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:border-indigo-400 transition"
/>
<p className="text-[13px] text-gray-400 text-right mt-1">
{text.length} / {MAX_CHARS}
</p>
</div>
<div className="flex gap-3">
<button
type="submit"
disabled={loading || !text.trim()}
className={`flex-1 text-base text-white rounded-xl py-3 font-semibold transition-all shadow-sm ${
loading
? 'bg-indigo-900 cursor-not-allowed'
: !text.trim()
? 'bg-indigo-600 dark:bg-indigo-800 opacity-50 cursor-not-allowed'
: 'bg-indigo-600 dark:bg-indigo-800 hover:bg-indigo-500 dark:hover:bg-indigo-700 hover:shadow-indigo-200 hover:shadow-md active:bg-indigo-700'
}`}
>
{loading ? LOADING_MESSAGES[msgIdx] : '🚀 Annotate'}
</button>
{loading && (
<button
type="button"
onClick={onStop}
className="px-7 text-base font-semibold text-red-600 dark:text-white border border-red-200 dark:border-red-700 rounded-xl bg-red-50 dark:bg-red-900 hover:bg-red-100 dark:hover:bg-red-800 active:bg-red-200 dark:active:bg-red-700 transition-colors"
>
✋ Stop
</button>
)}
</div>
</form>
)
}
interface DropdownProps {
options: { value: string; label: string }[]
value: string
onChange: (value: string) => void
}
function Dropdown({ options, value, onChange }: DropdownProps) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [open])
const selected = options.find((o) => o.value === value)
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="w-full flex items-center justify-between border border-gray-300 dark:border-gray-600 rounded-xl px-4 py-2.5 text-[15px] bg-gray-50 dark:bg-gray-700/60 text-gray-800 dark:text-gray-100 hover:border-gray-400 dark:hover:border-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:border-indigo-400 transition cursor-pointer"
>
<span>{selected?.label}</span>
<svg
className={`ml-2 shrink-0 text-gray-400 transition-transform ${open ? 'rotate-180' : ''}`}
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="6 9 12 15 18 9" />
</svg>
</button>
{open && (
<ul className="absolute z-20 mt-1 w-full bg-white/80 dark:bg-gray-800/90 backdrop-blur-md border border-gray-300 dark:border-gray-600 rounded-xl shadow-xl py-1 text-[15px]">
{options.map((o) => (
<li key={o.value}>
<button
type="button"
onClick={() => { onChange(o.value); setOpen(false) }}
className="w-full flex items-center justify-between px-3 py-2 text-left text-gray-800 dark:text-gray-100 hover:bg-gray-200/50 dark:hover:bg-gray-700/60 transition-colors"
>
<span>{o.label}</span>
{o.value === value && <span className="text-base"></span>}
</button>
</li>
))}
</ul>
)}
</div>
)
}