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