"use client"; import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import type { Role } from "@/lib/types"; /* ------------------------------------------------------------------ */ /* Generic autocomplete */ /* ------------------------------------------------------------------ */ interface SynsetHit { id: string; ili: string; pos: string; definition?: string; words: string[]; } interface WordHit { senseId: string; synsetId: string; lemma: string; pos: string; definition?: string; } function useDebounced(value: T, ms: number): T { const [v, setV] = useState(value); useEffect(() => { const t = setTimeout(() => setV(value), ms); return () => clearTimeout(t); }, [value, ms]); return v; } function Autocomplete({ placeholder, endpoint, render, keyOf, onSelect, selectedLabel, onClear, }: { placeholder: string; endpoint: string; render: (item: T) => React.ReactNode; keyOf: (item: T) => string; onSelect: (item: T) => void; selectedLabel?: string; onClear?: () => void; }) { const [q, setQ] = useState(""); const [open, setOpen] = useState(false); const [hits, setHits] = useState([]); const debounced = useDebounced(q, 250); const boxRef = useRef(null); useEffect(() => { if (debounced.trim().length < 2) { setHits([]); return; } let cancelled = false; fetch(`${endpoint}?q=${encodeURIComponent(debounced.trim())}`) .then((r) => r.json()) .then((d) => { if (!cancelled) { setHits(d.results ?? []); setOpen(true); } }) .catch(() => {}); return () => { cancelled = true; }; }, [debounced, endpoint]); useEffect(() => { function onDoc(e: MouseEvent) { if (!boxRef.current?.contains(e.target as Node)) setOpen(false); } document.addEventListener("mousedown", onDoc); return () => document.removeEventListener("mousedown", onDoc); }, []); if (selectedLabel) { return (
{selectedLabel}
); } return (
setQ(e.target.value)} onFocus={() => hits.length > 0 && setOpen(true)} placeholder={placeholder} className="font-bengali w-full rounded-xl border border-slate-300 px-3 py-2.5 text-sm outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-200" /> {open && hits.length > 0 ? (
    {hits.map((h) => (
  • ))}
) : null}
); } /* ------------------------------------------------------------------ */ /* Quick Input form (SRS §6.2) */ /* ------------------------------------------------------------------ */ const SENSE_REL_TYPES = ["derivation", "antonym", "metonym"] as const; const SYNSET_REL_TYPES = [ "hypernym", "mero_part", "mero_substance", "mero_member", "attribute", ] as const; interface RelRow { relType: string; target: string; label: string; } export function QuickInputForm({ role }: { role: Role }) { const router = useRouter(); const mountedAt = useRef(Date.now()); const [word, setWord] = useState(""); const [pos, setPos] = useState("n"); const [synsetAction, setSynsetAction] = useState<"link" | "create">("link"); const [selectedSynset, setSelectedSynset] = useState(null); const [ili, setIli] = useState(""); const [defLang, setDefLang] = useState<"ben" | "eng">("ben"); const [defText, setDefText] = useState(""); const [senseRels, setSenseRels] = useState([]); const [synsetRels, setSynsetRels] = useState([]); const [note, setNote] = useState(""); const [captcha, setCaptcha] = useState(false); const [honeypot, setHoneypot] = useState(""); const [directApprove, setDirectApprove] = useState(false); const [justification, setJustification] = useState(""); const [confirmNearDup, setConfirmNearDup] = useState(false); const [nearDupMessage, setNearDupMessage] = useState(null); const [errors, setErrors] = useState>({}); const [busy, setBusy] = useState(false); const [success, setSuccess] = useState(null); const isModerator = role === "SYSTEM_MODERATOR"; const lockedDefinition = synsetAction === "link" && selectedSynset?.definition ? true : false; const wordValid = word.trim().length > 0 && word.trim().length <= 25 && /[\u0980-\u09ff]/.test(word); async function submit(saveAsDraft: boolean) { setBusy(true); setErrors({}); setSuccess(null); setNearDupMessage(null); try { const res = await fetch("/api/submissions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ writtenForm: word.trim(), pos, synsetAction, synsetId: synsetAction === "link" ? selectedSynset?.id : undefined, iliCode: synsetAction === "create" ? ili.trim() : undefined, definitionLanguage: defLang, definitionText: lockedDefinition ? undefined : defText.trim() || undefined, senseRelations: senseRels .filter((r) => r.target) .map((r) => ({ relType: r.relType, target: r.target })), synsetRelations: synsetRels .filter((r) => r.target) .map((r) => ({ relType: r.relType, target: r.target })), reviewerNote: note.trim() || undefined, honeypot, elapsedSeconds: (Date.now() - mountedAt.current) / 1000, captchaPassed: captcha, confirmNearDuplicate: confirmNearDup, directApprove: isModerator ? directApprove : undefined, directApproveJustification: isModerator ? justification : undefined, saveAsDraft, }), }); const data = await res.json(); if (res.status === 409 && data.nearDuplicate) { setNearDupMessage(data.nearDuplicate); return; } if (!res.ok) { setErrors(data.errors ?? { form: data.error ?? "Submission failed." }); return; } setSuccess( saveAsDraft ? `Saved as draft (${data.id}). You can edit it from My Submissions.` : data.state === "VALIDATED" ? `Entry ${data.id} submitted and directly VALIDATED.` : `Entry ${data.id} submitted — now in PENDING_REVIEW.` ); // reset setWord(""); setSelectedSynset(null); setIli(""); setDefText(""); setSenseRels([]); setSynsetRels([]); setNote(""); setCaptcha(false); setConfirmNearDup(false); setDirectApprove(false); setJustification(""); mountedAt.current = Date.now(); router.refresh(); } finally { setBusy(false); } } const fieldError = (key: string) => errors[key] ? (

{errors[key]}

) : null; return (
{ e.preventDefault(); submit(false); }} > {success ? (
✓ {success}
) : null} {errors.form || errors.timing || errors.captcha || errors.duplicate ? (
{errors.form ?? errors.timing ?? errors.captcha ?? errors.duplicate}
) : null} {nearDupMessage ? (

⚠ Possible duplicate

{nearDupMessage}

) : null} {/* honeypot — invisible to humans (SRS §4.3) */} setHoneypot(e.target.value)} name="website_url" tabIndex={-1} autoComplete="off" aria-hidden="true" className="absolute -left-[9999px] h-0 w-0 opacity-0" />
{/* Bengali word */}
setWord(e.target.value)} onBlur={() => setWord((w) => w.trim())} placeholder="যেমন: মুঠোফোন" lang="bn" maxLength={25} className={`font-bengali w-full rounded-xl border px-3 py-2.5 text-lg outline-none transition focus:ring-2 ${ word && !wordValid ? "border-rose-300 focus:border-rose-500 focus:ring-rose-200" : "border-slate-300 focus:border-emerald-500 focus:ring-emerald-200" }`} />

Bengali Unicode (U+0980–U+09FF) · max 25 characters ·{" "} {25 - word.length} left

{fieldError("writtenForm")}
{/* POS */}
{fieldError("pos")}
{/* Synset action */}
Concept (Synset) linkage *
{( [ ["link", "Link to existing synset"], ["create", "Create new synset (requires ILI code)"], ] as const ).map(([val, label]) => ( ))}
{synsetAction === "link" ? (
placeholder="Search by synset ID, member word or definition…" endpoint="/api/lookup/synsets" keyOf={(h) => h.id} onSelect={(h) => setSelectedSynset(h)} selectedLabel={ selectedSynset ? `${selectedSynset.id} (${selectedSynset.pos}) — ${selectedSynset.words.join(", ") || "no words"} ${selectedSynset.definition ? `· ${selectedSynset.definition}` : ""}` : undefined } onClear={() => setSelectedSynset(null)} render={(h) => ( {h.id} {" "} {h.pos} {" "} {h.words.join(", ")} {h.definition ? ( {h.definition} ) : null} )} /> {selectedSynset && selectedSynset.pos !== pos ? (

POS mismatch: this synset is “{selectedSynset.pos}” but your lemma is “{pos}”.

) : null} {fieldError("synsetId")}
) : (
setIli(e.target.value)} placeholder="ILI code, e.g. 04192858-n" className="w-full rounded-xl border border-slate-300 px-3 py-2.5 font-mono text-sm outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-200" />

Format: {`{8-digit-zero-padded-offset}-{n|v|a|r}`} — validated against the local Princeton WordNet 2.1 ILI registry cache.

{fieldError("iliCode")}
)} {fieldError("synsetAction")}
{/* Definition */}
{lockedDefinition ? (
🔒 {selectedSynset?.definition} The selected synset already has a definition — field locked (SRS §6.2).
) : (