reverse-etymology / frontend /src /WordModal.tsx
Mongoosetross's picture
Deploy Reverse Etymology Atlas
d8db673 verified
Raw
History Blame Contribute Delete
8.13 kB
import { useEffect, useId, useRef, Fragment } from "react";
import type { GraphNode, SuggestHit } from "./types";
export type DefinitionSense = { pos: string; glosses: string[] };
export type DefinitionPayload = {
term: string;
lang?: string;
matched_code?: string | null;
senses?: DefinitionSense[];
fallback?: boolean;
url?: string;
error?: string | null;
};
type Detail = Record<string, unknown> | null;
type CognateSet = {
id: number;
language_count: number;
word_count: number;
words: { term?: string; lang?: string }[];
};
type WalsRow = { feature_id: string; feature_name: string; value_label: string };
type PhonemeRow = { phoneme: string; tone?: number };
export function WordModal({
open,
node,
detail,
definition,
definitionLoading,
onClose,
onDescend,
onLeafLang,
onPickEtymon,
}: {
open: boolean;
node: GraphNode | null;
detail: Detail;
definition: DefinitionPayload | null;
definitionLoading: boolean;
onClose: () => void;
onDescend: () => void;
onLeafLang: () => void;
onPickEtymon: (term: string, lang: string) => void;
}) {
const titleId = useId();
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const prev = document.activeElement as HTMLElement | null;
closeRef.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
const overflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = overflow;
prev?.focus?.();
};
}, [open, onClose]);
if (!open || !node) return null;
const etymons = ((detail?.etymons as SuggestHit[]) || []).slice(0, 8);
const cognates = ((detail?.cognate_sets as CognateSet[]) || []).slice(0, 3);
const childRels = (detail?.child_relations as Record<string, number>) || {};
const wals = ((detail?.wals as WalsRow[]) || []).slice(0, 6);
const phonemes = (detail?.phonemes as PhonemeRow[]) || [];
const wiki = (detail?.wiktionary as string) || definition?.url;
const senses = definition?.senses || [];
return (
<div className="modal-root" role="presentation">
<button type="button" className="modal-backdrop" aria-label="Close word details" onClick={onClose} />
<div className="modal-dialog" role="dialog" aria-modal="true" aria-labelledby={titleId}>
<header className="modal-head">
<div>
<h2 id={titleId}>{node.term}</h2>
<p>
{node.lang_display || node.lang}
{node.family_name ? ` · ${node.family_name}` : ""}
{node.relation ? ` · ${node.relation}` : node.depth === 0 ? " · root" : ""}
{node.confidence != null ? ` · ${Number(node.confidence).toFixed(2)}` : ""}
</p>
</div>
<button ref={closeRef} type="button" className="ghost sheet-close" onClick={onClose}>
Close
</button>
</header>
<div className="modal-body">
<section>
<h3>Definition</h3>
{definitionLoading ? (
<p className="modal-muted">Looking up Wiktionary…</p>
) : senses.length ? (
<>
{definition?.fallback ? (
<p className="modal-muted">
No exact language match — showing Wiktionary glosses for [{definition.matched_code}].
</p>
) : null}
<div className="modal-senses">
{senses.map((s) => (
<div key={s.pos} className="modal-sense">
<div className="modal-pos">{s.pos}</div>
<ol>
{s.glosses.map((g) => (
<li key={g}>{g}</li>
))}
</ol>
</div>
))}
</div>
</>
) : (
<p className="modal-muted">
No Wiktionary gloss found for this spelling
{definition?.error ? ` (${definition.error})` : ""}.
{wiki ? (
<>
{" "}
<a href={wiki} target="_blank" rel="noreferrer">
Open Wiktionary
</a>
</>
) : null}
</p>
)}
</section>
<section>
<h3>In this graph</h3>
<dl className="kv">
<dt>Depth</dt>
<dd>{node.depth}</dd>
<dt>Children</dt>
<dd>{node.child_count ?? "—"}</dd>
<dt>Area</dt>
<dd>{node.macroarea || "—"}</dd>
<dt>Status</dt>
<dd>{node.status || "—"}</dd>
<dt>Glottocode</dt>
<dd>{node.glottocode || "—"}</dd>
<dt>ISO 639-3</dt>
<dd>{node.iso_639_3 || "—"}</dd>
</dl>
{!!Object.keys(childRels).length && (
<p className="modal-chips">
{Object.entries(childRels).map(([k, v]) => (
<span key={k} className="chip">
{k.replace("_", " ")} {v}
</span>
))}
</p>
)}
</section>
{!!etymons.length && (
<section>
<h3>Comes from</h3>
<div className="path-list">
{etymons.map((e, i) => (
<button key={`${e.term}-${e.lang}-${i}`} type="button" onClick={() => onPickEtymon(e.term, e.lang)}>
{e.term} · {e.lang_display || e.lang}
{(e as unknown as { relation?: string }).relation
? ` · ${(e as unknown as { relation?: string }).relation}`
: ""}
</button>
))}
</div>
</section>
)}
{!!cognates.length && (
<section>
<h3>Cognate sets</h3>
{cognates.map((c) => (
<p key={c.id} className="modal-muted">
{c.language_count} languages · {(c.words || []).slice(0, 8).map((w) => w.term).filter(Boolean).join(", ")}
{(c.words || []).length > 8 ? "…" : ""}
</p>
))}
</section>
)}
{!!wals.length && (
<section>
<h3>Typology (WALS sample)</h3>
<dl className="kv">
{wals.map((w) => (
<Fragment key={w.feature_id}>
<dt title={w.feature_name}>{w.feature_id}</dt>
<dd>{w.value_label}</dd>
</Fragment>
))}
</dl>
</section>
)}
{!!phonemes.length && (
<section>
<h3>Phonemes</h3>
<p className="modal-muted">
{phonemes.length} segments
{phonemes.some((p) => p.tone) ? " · has tone" : ""}
{" · "}
{phonemes
.slice(0, 24)
.map((p) => p.phoneme)
.join(" ")}
{phonemes.length > 24 ? " …" : ""}
</p>
</section>
)}
</div>
<footer className="modal-foot">
{node.kind === "word" && (
<>
<button type="button" className="ghost primary" onClick={onDescend}>
Descend from here
</button>
<button type="button" className="ghost" onClick={onLeafLang}>
Leaf = this language
</button>
</>
)}
{wiki && (
<a className="ghost" href={wiki} target="_blank" rel="noreferrer">
Wiktionary
</a>
)}
</footer>
</div>
</div>
);
}