bengali-lkc / src /components /dashboard /QuickInputForm.tsx
saifulislamoni's picture
Deploy Bengali LKC
d1cda0b
Raw
History Blame Contribute Delete
27.1 kB
"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<T>(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<T>({
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<T[]>([]);
const debounced = useDebounced(q, 250);
const boxRef = useRef<HTMLDivElement>(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 (
<div className="flex items-center gap-2 rounded-xl border border-emerald-300 bg-emerald-50 px-3 py-2.5">
<span className="font-bengali min-w-0 flex-1 truncate text-sm font-semibold text-emerald-900">
{selectedLabel}
</span>
<button
type="button"
onClick={onClear}
className="text-xs font-bold text-emerald-700 hover:text-rose-600"
>
✕ change
</button>
</div>
);
}
return (
<div ref={boxRef} className="relative">
<input
value={q}
onChange={(e) => 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 ? (
<ul className="absolute z-20 mt-1 max-h-72 w-full overflow-y-auto rounded-xl border border-slate-200 bg-white shadow-xl">
{hits.map((h) => (
<li key={keyOf(h)}>
<button
type="button"
onClick={() => {
onSelect(h);
setOpen(false);
setQ("");
}}
className="block w-full px-3 py-2.5 text-left text-sm transition hover:bg-emerald-50"
>
{render(h)}
</button>
</li>
))}
</ul>
) : null}
</div>
);
}
/* ------------------------------------------------------------------ */
/* 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<SynsetHit | null>(null);
const [ili, setIli] = useState("");
const [defLang, setDefLang] = useState<"ben" | "eng">("ben");
const [defText, setDefText] = useState("");
const [senseRels, setSenseRels] = useState<RelRow[]>([]);
const [synsetRels, setSynsetRels] = useState<RelRow[]>([]);
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<string | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [success, setSuccess] = useState<string | null>(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] ? (
<p className="mt-1 text-xs font-medium text-rose-600">{errors[key]}</p>
) : null;
return (
<form
className="space-y-6"
onSubmit={(e) => {
e.preventDefault();
submit(false);
}}
>
{success ? (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-800">
✓ {success}
</div>
) : null}
{errors.form || errors.timing || errors.captcha || errors.duplicate ? (
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-medium text-rose-700">
{errors.form ?? errors.timing ?? errors.captcha ?? errors.duplicate}
</div>
) : null}
{nearDupMessage ? (
<div className="rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<p className="font-semibold">⚠ Possible duplicate</p>
<p className="mt-1">{nearDupMessage}</p>
<label className="mt-2 flex items-center gap-2 text-sm font-semibold">
<input
type="checkbox"
checked={confirmNearDup}
onChange={(e) => setConfirmNearDup(e.target.checked)}
className="h-4 w-4 accent-amber-600"
/>
I confirm this is a distinct meaning (legitimate polysemy)
</label>
</div>
) : null}
{/* honeypot — invisible to humans (SRS §4.3) */}
<input
type="text"
value={honeypot}
onChange={(e) => setHoneypot(e.target.value)}
name="website_url"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
className="absolute -left-[9999px] h-0 w-0 opacity-0"
/>
<div className="grid gap-5 sm:grid-cols-2">
{/* Bengali word */}
<div>
<label className="mb-1.5 block text-sm font-semibold text-slate-700">
Bengali word (writtenForm) <span className="text-rose-500">*</span>
</label>
<input
value={word}
onChange={(e) => 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"
}`}
/>
<p className="mt-1 text-[11px] text-slate-400">
Bengali Unicode (U+0980–U+09FF) · max 25 characters ·{" "}
{25 - word.length} left
</p>
{fieldError("writtenForm")}
</div>
{/* POS */}
<div>
<label className="mb-1.5 block text-sm font-semibold text-slate-700">
Part of speech <span className="text-rose-500">*</span>
</label>
<select
value={pos}
onChange={(e) => {
setPos(e.target.value);
setSelectedSynset(null);
}}
className="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"
>
<option value="n">Noun (n) — বিশেষ্য</option>
<option value="v">Verb (v) — ক্রিয়া</option>
<option value="a">Adjective (a) — বিশেষণ</option>
<option value="r">Adverb (r) — ক্রিয়া বিশেষণ</option>
</select>
{fieldError("pos")}
</div>
</div>
{/* Synset action */}
<fieldset className="rounded-2xl border border-slate-200 bg-slate-50/50 p-5">
<legend className="px-2 text-sm font-bold text-slate-700">
Concept (Synset) linkage <span className="text-rose-500">*</span>
</legend>
<div className="flex flex-wrap gap-5">
{(
[
["link", "Link to existing synset"],
["create", "Create new synset (requires ILI code)"],
] as const
).map(([val, label]) => (
<label key={val} className="flex items-center gap-2 text-sm font-medium text-slate-700">
<input
type="radio"
name="synsetAction"
checked={synsetAction === val}
onChange={() => setSynsetAction(val)}
className="h-4 w-4 accent-emerald-700"
/>
{label}
</label>
))}
</div>
{synsetAction === "link" ? (
<div className="mt-4">
<Autocomplete<SynsetHit>
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) => (
<span>
<span className="font-mono text-xs font-bold text-emerald-700">
{h.id}
</span>{" "}
<span className="rounded bg-slate-100 px-1 text-[10px] font-bold uppercase">
{h.pos}
</span>{" "}
<span className="font-bengali">{h.words.join(", ")}</span>
{h.definition ? (
<span className="font-bengali block text-xs text-slate-400">
{h.definition}
</span>
) : null}
</span>
)}
/>
{selectedSynset && selectedSynset.pos !== pos ? (
<p className="mt-1 text-xs font-medium text-rose-600">
POS mismatch: this synset is “{selectedSynset.pos}” but your
lemma is “{pos}”.
</p>
) : null}
{fieldError("synsetId")}
</div>
) : (
<div className="mt-4">
<input
value={ili}
onChange={(e) => 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"
/>
<p className="mt-1 text-[11px] text-slate-400">
Format: {`{8-digit-zero-padded-offset}-{n|v|a|r}`} — validated
against the local Princeton WordNet 2.1 ILI registry cache.
</p>
{fieldError("iliCode")}
</div>
)}
{fieldError("synsetAction")}
</fieldset>
{/* Definition */}
<div className="grid gap-5 sm:grid-cols-[10rem_1fr]">
<div>
<label className="mb-1.5 block text-sm font-semibold text-slate-700">
Definition language
</label>
<select
value={defLang}
onChange={(e) => setDefLang(e.target.value as "ben" | "eng")}
disabled={lockedDefinition}
className="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 disabled:bg-slate-100"
>
<option value="ben">Bengali (ben)</option>
<option value="eng">English (eng)</option>
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-semibold text-slate-700">
Definition text{" "}
<span className="font-normal text-slate-400">(optional, max 500)</span>
</label>
{lockedDefinition ? (
<div className="font-bengali rounded-xl border border-slate-200 bg-slate-100 px-3 py-2.5 text-sm text-slate-600">
🔒 {selectedSynset?.definition}
<span className="mt-1 block text-[11px] text-slate-400">
The selected synset already has a definition — field locked
(SRS §6.2).
</span>
</div>
) : (
<textarea
value={defText}
onChange={(e) => setDefText(e.target.value)}
maxLength={500}
rows={3}
lang="bn"
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"
/>
)}
{fieldError("definitionText")}
</div>
</div>
{/* Relations */}
<div className="grid gap-5 lg:grid-cols-2">
{/* Sense relations */}
<fieldset className="rounded-2xl border border-slate-200 p-5">
<legend className="px-2 text-sm font-bold text-slate-700">
Sense relations <span className="font-normal text-slate-400">(optional)</span>
</legend>
<div className="space-y-3">
{senseRels.map((rel, i) => (
<div key={i} className="flex items-center gap-2">
<select
value={rel.relType}
onChange={(e) =>
setSenseRels((rs) =>
rs.map((r, j) => (j === i ? { ...r, relType: e.target.value } : r))
)
}
className="w-32 shrink-0 rounded-lg border border-slate-300 px-2 py-2 text-xs"
>
{SENSE_REL_TYPES.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
<div className="min-w-0 flex-1">
{rel.target ? (
<span className="font-bengali block truncate rounded-lg bg-emerald-50 px-2 py-2 text-xs font-semibold text-emerald-900 ring-1 ring-inset ring-emerald-200">
{rel.label}
</span>
) : (
<Autocomplete<WordHit>
placeholder="Target word…"
endpoint="/api/lookup/words"
keyOf={(h) => h.senseId}
onSelect={(h) =>
setSenseRels((rs) =>
rs.map((r, j) =>
j === i
? { ...r, target: h.senseId, label: `${h.lemma} (${h.senseId})` }
: r
)
)
}
render={(h) => (
<span>
<span className="font-bengali font-semibold">{h.lemma}</span>{" "}
<span className="font-mono text-[10px] text-slate-400">{h.senseId}</span>
{h.definition ? (
<span className="font-bengali block text-xs text-slate-400">{h.definition}</span>
) : null}
</span>
)}
/>
)}
</div>
<button
type="button"
onClick={() => setSenseRels((rs) => rs.filter((_, j) => j !== i))}
className="shrink-0 text-slate-400 hover:text-rose-600"
aria-label="Remove relation"
>
</button>
</div>
))}
<button
type="button"
onClick={() =>
setSenseRels((rs) => [...rs, { relType: "derivation", target: "", label: "" }])
}
className="rounded-lg border border-dashed border-slate-300 px-3 py-2 text-xs font-semibold text-slate-500 transition hover:border-emerald-400 hover:text-emerald-700"
>
+ Add sense relation
</button>
</div>
</fieldset>
{/* Synset relations */}
<fieldset className="rounded-2xl border border-slate-200 p-5">
<legend className="px-2 text-sm font-bold text-slate-700">
Synset relations <span className="font-normal text-slate-400">(optional)</span>
</legend>
<div className="space-y-3">
{synsetRels.map((rel, i) => (
<div key={i} className="flex items-center gap-2">
<select
value={rel.relType}
onChange={(e) =>
setSynsetRels((rs) =>
rs.map((r, j) => (j === i ? { ...r, relType: e.target.value } : r))
)
}
className="w-32 shrink-0 rounded-lg border border-slate-300 px-2 py-2 text-xs"
>
{SYNSET_REL_TYPES.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
<div className="min-w-0 flex-1">
{rel.target ? (
<span className="font-bengali block truncate rounded-lg bg-emerald-50 px-2 py-2 text-xs font-semibold text-emerald-900 ring-1 ring-inset ring-emerald-200">
{rel.label}
</span>
) : (
<Autocomplete<SynsetHit>
placeholder="Target synset…"
endpoint="/api/lookup/synsets"
keyOf={(h) => h.id}
onSelect={(h) =>
setSynsetRels((rs) =>
rs.map((r, j) =>
j === i
? { ...r, target: h.id, label: `${h.words.join(", ") || h.id} (${h.id})` }
: r
)
)
}
render={(h) => (
<span>
<span className="font-mono text-xs font-bold text-emerald-700">{h.id}</span>{" "}
<span className="font-bengali">{h.words.join(", ")}</span>
</span>
)}
/>
)}
</div>
<button
type="button"
onClick={() => setSynsetRels((rs) => rs.filter((_, j) => j !== i))}
className="shrink-0 text-slate-400 hover:text-rose-600"
aria-label="Remove relation"
>
</button>
</div>
))}
<button
type="button"
onClick={() =>
setSynsetRels((rs) => [...rs, { relType: "hypernym", target: "", label: "" }])
}
className="rounded-lg border border-dashed border-slate-300 px-3 py-2 text-xs font-semibold text-slate-500 transition hover:border-emerald-400 hover:text-emerald-700"
>
+ Add synset relation
</button>
</div>
</fieldset>
</div>
{/* Notes */}
<div>
<label className="mb-1.5 block text-sm font-semibold text-slate-700">
Notes for reviewers{" "}
<span className="font-normal text-slate-400">
(optional — internal, never exported to XML)
</span>
</label>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
maxLength={500}
rows={2}
className="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"
/>
</div>
{/* SM direct approval */}
{isModerator ? (
<div className="rounded-2xl border border-purple-200 bg-purple-50/60 p-5">
<label className="flex items-center gap-2 text-sm font-bold text-purple-900">
<input
type="checkbox"
checked={directApprove}
onChange={(e) => setDirectApprove(e.target.checked)}
className="h-4 w-4 accent-purple-700"
/>
⚡ Direct Approval — promote immediately to VALIDATED, bypassing LE
and CLE review
</label>
{directApprove ? (
<div className="mt-3">
<textarea
value={justification}
onChange={(e) => setJustification(e.target.value)}
rows={2}
placeholder="Mandatory written justification (minimum 20 characters)…"
className="w-full rounded-xl border border-purple-300 px-3 py-2.5 text-sm outline-none focus:ring-2 focus:ring-purple-200"
/>
{fieldError("directApproveJustification")}
</div>
) : null}
</div>
) : null}
{/* CAPTCHA simulation */}
{!isModerator ? (
<label className="flex w-fit cursor-pointer items-center gap-3 rounded-xl border border-slate-300 bg-white px-4 py-3 shadow-sm">
<input
type="checkbox"
checked={captcha}
onChange={(e) => setCaptcha(e.target.checked)}
className="h-5 w-5 accent-emerald-700"
/>
<span className="text-sm font-medium text-slate-700">
I&apos;m not a robot
</span>
<span className="ml-2 text-[10px] uppercase tracking-wide text-slate-300">
hCaptcha simulation
</span>
</label>
) : null}
<div className="flex flex-wrap items-center gap-3 border-t border-slate-100 pt-5">
<button
type="submit"
disabled={busy}
className="rounded-xl bg-emerald-700 px-7 py-3 text-sm font-bold text-white shadow-sm transition hover:bg-emerald-800 disabled:opacity-50"
>
{busy ? "Submitting…" : "Submit for Review"}
</button>
<button
type="button"
disabled={busy}
onClick={() => submit(true)}
className="rounded-xl border border-slate-300 bg-white px-6 py-3 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 disabled:opacity-50"
>
Save as Draft
</button>
<p className="text-[11px] text-slate-400">
Server checks: honeypot → timing (≥10s) → CAPTCHA → duplicate
detection (SRS §6.2).
</p>
</div>
</form>
);
}