Spaces:
Sleeping
Sleeping
| "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'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> | |
| ); | |
| } | |