diff options
author | polwex <polwex@sortug.com> | 2025-06-03 09:34:29 +0700 |
---|---|---|
committer | polwex <polwex@sortug.com> | 2025-06-03 09:34:29 +0700 |
commit | 2401217a4019938d1c1cc61b6e33ccb233eb6e74 (patch) | |
tree | 06118284965be5cfd6b417dca86d46db5758217b /src/components | |
parent | 2b80f7950df34f2a160135d7e20220a9b2ec3352 (diff) |
this is golden thanks claude
Diffstat (limited to 'src/components')
-rw-r--r-- | src/components/Flashcard/Deck3.tsx | 223 | ||||
-rw-r--r-- | src/components/Flashcard/ServerCard2.tsx | 359 | ||||
-rw-r--r-- | src/components/prosody/ClientCard.tsx | 64 | ||||
-rw-r--r-- | src/components/prosody/ServerCard.tsx | 343 |
4 files changed, 989 insertions, 0 deletions
diff --git a/src/components/Flashcard/Deck3.tsx b/src/components/Flashcard/Deck3.tsx new file mode 100644 index 0000000..4f0f711 --- /dev/null +++ b/src/components/Flashcard/Deck3.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { CardResponse, DeckResponse } from "@/lib/types/cards"; +import React, { + ReactNode, + useCallback, + useEffect, + useState, + useTransition, +} from "react"; +import { Button } from "../ui/button"; +import { ChevronLeftIcon, ChevronRightIcon, RotateCcwIcon } from "lucide-react"; +import "./cards.css"; + +type CardData = { + id: number; + front: ReactNode; + back: ReactNode; +}; +// --- Main App Component --- +function Deck({ data, cards }: { data: any; cards: CardData[] }) { + const [currentPage, setCurrentPage] = useState<number>(0); + const [currentIndex, setCurrentIndex] = useState<number>(0); + const [isFlipped, setIsFlipped] = useState<boolean>(false); + const [animationDirection, setAnimationDirection] = useState< + "enter-left" | "enter-right" | "exit-left" | "exit-right" | "none" + >("none"); + const [isAnimating, setIsAnimating] = useState<boolean>(false); + + const handleFlip = () => { + if (isAnimating) return; + setIsFlipped(!isFlipped); + }; + + const handleNext = useCallback(() => { + if (isAnimating || currentIndex >= cards.length - 1) return; + setIsAnimating(true); + setIsFlipped(false); // Flip back to front before changing card + setAnimationDirection("exit-left"); + + setTimeout(() => { + setCurrentIndex((prevIndex) => Math.min(prevIndex + 1, cards.length - 1)); + setAnimationDirection("enter-right"); + setTimeout(() => { + setAnimationDirection("none"); + setIsAnimating(false); + }, 200); // Duration of enter animation + }, 200); // Duration of exit animation + }, [currentIndex, cards.length, isAnimating]); + + const handlePrev = useCallback(() => { + if (isAnimating || currentIndex <= 0) return; + setIsAnimating(true); + setIsFlipped(false); // Flip back to front + setAnimationDirection("exit-right"); + + setTimeout(() => { + setCurrentIndex((prevIndex) => Math.max(prevIndex - 1, 0)); + setAnimationDirection("enter-left"); + setTimeout(() => { + setAnimationDirection("none"); + setIsAnimating(false); + }, 200); // Duration of enter animation + }, 200); // Duration of exit animation + }, [currentIndex, isAnimating]); + + // Keyboard navigation + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (isAnimating) return; + if (event.key === "ArrowRight") { + handleNext(); + } else if (event.key === "ArrowLeft") { + handlePrev(); + } else if (event.key === " " || event.key === "Enter") { + // Space or Enter to flip + event.preventDefault(); // Prevent scrolling if space is pressed + handleFlip(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [handleNext, handlePrev, isAnimating]); + + const [isPending, startTransition] = useTransition(); + const shuffle = () => { + startTransition(async () => { + "use server"; + console.log("shuffling deck..."); + }); + }; + + if (cards.length === 0) { + return ( + <div className="min-h-screen bg-slate-50 dark:bg-slate-900 flex flex-col items-center justify-center p-4 font-inter text-slate-800 dark:text-slate-200"> + <p>No flashcards available.</p> + </div> + ); + } + + const currentCard = cards[currentIndex]; + if (!currentCard) return <p>wtf</p>; + + return ( + <div className="min-h-screen bg-slate-100 dark:bg-slate-900 flex flex-col items-center justify-center p-4 font-inter transition-colors duration-300"> + <header> + <h1 className="text-2xl ">Deck: {data.lesson.name}</h1> + <p>{data.lesson.description}</p> + </header> + <div className="w-full max-w-md mb-8 relative"> + {/* This div is for positioning the card and managing overflow during animations */} + <div className="relative h-80"> + <FlashCard + key={currentCard.id} // Important for re-rendering on card change with animation + isFlipped={isFlipped} + onFlip={handleFlip} + animationDirection={animationDirection} + front={currentCard.front} + back={currentCard.back} + /> + </div> + </div> + + <div className="flex items-center justify-between w-full max-w-md mb-6"> + <Button + onClick={handlePrev} + disabled={currentIndex === 0 || isAnimating} + variant="outline" + size="icon" + aria-label="Previous card" + > + <ChevronLeftIcon /> + </Button> + <div className="text-center"> + <p className="text-sm text-slate-600 dark:text-slate-400"> + Card {currentIndex + 1} of {cards.length} + </p> + <Button + onClick={handleFlip} + variant="ghost" + size="sm" + className="mt-1 text-slate-600 dark:text-slate-400" + disabled={isAnimating} + > + <RotateCcwIcon className="w-4 h-4 mr-2" /> Flip Card + </Button> + </div> + <Button + onClick={handleNext} + disabled={currentIndex === cards.length - 1 || isAnimating} + variant="outline" + size="icon" + aria-label="Next card" + > + <ChevronRightIcon /> + </Button> + </div> + + <div className="text-xs text-slate-500 dark:text-slate-400 mt-8"> + Use Arrow Keys (← →) to navigate, Space/Enter to flip. + </div> + <Button onClick={shuffle}>Shuffle Deck</Button> + </div> + ); +} + +export default Deck; + +interface FlashcardProps { + isFlipped: boolean; + onFlip: () => void; + animationDirection: + | "enter-left" + | "enter-right" + | "exit-left" + | "exit-right" + | "none"; +} +interface ServerCards { + front: ReactNode; + back: ReactNode; +} + +function FlashCard({ + isFlipped, + onFlip, + animationDirection, + front, + back, +}: FlashcardProps & ServerCards) { + const getAnimationClass = () => { + switch (animationDirection) { + case "enter-right": + return "animate-slide-in-right"; + case "enter-left": + return "animate-slide-in-left"; + case "exit-right": + return "animate-slide-out-right"; + case "exit-left": + return "animate-slide-out-left"; + default: + return ""; + } + }; + return ( + <div + className={`w-full max-w-md h-80 perspective group ${getAnimationClass()}`} + onClick={onFlip} + > + <div + className={`relative w-full h-full rounded-xl shadow-xl transition-transform duration-700 ease-in-out transform-style-preserve-3d cursor-pointer ${ + isFlipped ? "rotate-y-180" : "" + }`} + > + {front} + {back} + </div> + </div> + ); +} diff --git a/src/components/Flashcard/ServerCard2.tsx b/src/components/Flashcard/ServerCard2.tsx new file mode 100644 index 0000000..d8a4989 --- /dev/null +++ b/src/components/Flashcard/ServerCard2.tsx @@ -0,0 +1,359 @@ +// This is a Server Component +import React, { Suspense, useTransition } from "react"; +import { NLP } from "sortug-ai"; +import { + BookOpen, + Volume2, + Link as LinkIcon, + ChevronDown, + ChevronUp, + Search, + Info, + MessageSquareQuote, + Tags, + ListTree, + Lightbulb, + BookmarkIcon, +} from "lucide-react"; +import { + Example, + SubSense, + RelatedEntry, + Sense, + WordData, +} from "@/zoom/logic/types"; +import { CardResponse, ProsodyWord } from "@/lib/types/cards"; +import { thaiData } from "@/lib/calls/nlp"; +import { getRandomHexColor } from "@/lib/utils"; +import { BookmarkIconito } from "./BookmarkButton"; +import SyllableSpan from "./SyllableSpan"; +import SyllableCard from "./Syllable"; + +export async function CardFront({ data }: { data: ProsodyWord }) { + console.log("cardfront", data); + return ( + <div className="absolute w-full h-full bg-white dark:bg-slate-800 rounded-xl backface-hidden flex flex-col justify-center gap-8 items-center p-6"> + <Suspense + fallback={ + <p className="text-5xl cursor-pointer hover:text-blue-700 font-semibold text-slate-800 dark:text-slate-100 text-center"> + {data.spelling} + </p> + } + > + <p className="text-5xl cursor-pointer font-semibold text-slate-800 dark:text-slate-100 text-center"> + {data.syllables.map((syl, i) => ( + // <span + // key={syl + i} + // style={{ color: getRandomHexColor() }} + // className="m-1 hover:text-6xl" + // > + // {syl} + // </span> + <SyllableSpan + key={syl.spelling + i} + spelling={syl.spelling} + lang={data.lang} + /> + ))} + </p> + </Suspense> + <p>Word: {data.id}</p> + <IpaDisplay ipaEntries={[{ ipa: data.ipa }]} /> + </div> + ); +} + +// Helper component for IPA display +export const IpaDisplay = ({ + ipaEntries, +}: { + ipaEntries: Array<{ ipa: string; tags?: string[] }>; +}) => { + if (!ipaEntries || ipaEntries.length === 0) return null; + return ( + <div className="flex items-center space-x-2 flex-wrap"> + {ipaEntries.map((entry, index) => { + const tags = entry.tags ? entry.tags : []; + return ( + <span key={index} className="text-lg text-blue-600 font-serif"> + {entry.ipa}{" "} + {tags.length > 0 && ( + <span className="text-xs text-gray-500">({tags.join(", ")})</span> + )} + </span> + ); + })} + <button + className="p-1 text-blue-500 hover:text-blue-700 transition-colors" + title="Pronounce" + // onClick={() => { + // /* Pronunciation logic would be client-side or a server roundtrip for audio file. */ alert( + // "Pronunciation feature not implemented for server component.", + // ); + // }} + > + <Volume2 size={20} /> + </button> + </div> + ); +}; + +export async function CardBack({ data }: { data: ProsodyWord }) { + return ( + <div className="w-full h-full bg-slate-50 dark:bg-slate-700 rounded-xl backface-hidden rotate-y-180 flex flex-col justify-between items-center p-6 relative"> + <span className="text-lg text-slate-500 dark:text-slate-400 self-start"> + {data.syllables.map((syl, i) => ( + <div key={syl.spelling + i}> + <div>whole: {syl.ipa}</div> + <div className="text-blue-500 hover:text-blue-700 hover:underline"> + onset: {syl.onset} + </div> + <div className="text-blue-500 hover:text-blue-700 hover:underline"> + nucleus: {syl.nucleus} + </div> + <div className="text-blue-500 hover:text-blue-700 hover:underline"> + coda: {syl.coda} + </div> + <div className="text-blue-500 hover:text-blue-700 hover:underline"> + tone: {syl.tone} + </div> + <div className="text-blue-500 hover:text-blue-700 hover:underline"> + tonename: {syl.tonen} + </div> + </div> + ))} + </span> + <p className="text-3xl md:text-4xl font-semibold text-slate-800 dark:text-slate-100 text-center"> + {data.notes || ""} + </p> + </div> + ); +} + +// Component for displaying examples +const ExampleDisplay = ({ examples }: { examples: Example[] }) => { + if (!examples || examples.length === 0) return null; + return ( + <div className="mt-2"> + <h5 className="text-xs font-semibold text-gray-600 mb-1 flex items-center"> + <MessageSquareQuote size={14} className="mr-1 text-gray-500" /> + Examples: + </h5> + <ul className="list-disc list-inside pl-2 space-y-1"> + {examples.map((ex, idx) => ( + <li key={idx} className="text-xs text-gray-600"> + <span className="italic">"{ex.text}"</span> + {ex.ref && ( + <span className="text-gray-400 text-xs"> ({ex.ref})</span> + )} + {ex.type !== "quote" && ( + <span className="ml-1 text-xs bg-sky-100 text-sky-700 px-1 rounded-sm"> + {ex.type} + </span> + )} + </li> + ))} + </ul> + </div> + ); +}; + +// Component for displaying related terms (synonyms, antonyms, etc.) +const RelatedTermsDisplay = ({ + terms, + type, +}: { + terms: RelatedEntry[] | undefined; + type: string; +}) => { + if (!terms || terms.length === 0) return null; + return ( + <div className="mt-1"> + <span className="text-xs font-semibold text-gray-500 capitalize"> + {type}:{" "} + </span> + {terms.map((term, idx) => ( + <React.Fragment key={idx}> + <a + href={`/search?q=${encodeURIComponent(term.word)}`} + className="text-xs text-blue-500 hover:text-blue-700 hover:underline" + > + {term.word} + </a> + {/*term.source && ( + <span className="text-xs text-gray-400"> ({term.source})</span> + )*/} + {idx < terms.length - 1 && ", "} + </React.Fragment> + ))} + </div> + ); +}; + +// Component for displaying a SubSense +const SubSenseDisplay = ({ + subSense, + subSenseNumber, +}: { + subSense: SubSense; + subSenseNumber: number; +}) => { + return ( + <div className="mb-3 pl-4 border-l-2 border-indigo-200"> + {subSense.glosses.map((gloss, glossIdx) => ( + <p key={glossIdx} className="text-gray-700 mb-1"> + <span className="font-semibold"> + {subSenseNumber}.{glossIdx + 1} + </span>{" "} + {gloss} + </p> + ))} + {subSense.raw_glosses && + subSense.raw_glosses.length > 0 && + subSense.raw_glosses.join("") !== subSense.glosses.join("") && ( + <p className="text-xs text-gray-500 italic mb-1"> + (Raw: {subSense.raw_glosses.join("; ")}) + </p> + )} + + {subSense.categories && subSense.categories.length > 0 && ( + <div className="mt-1 mb-2"> + <h5 className="text-xs font-semibold text-gray-600 mb-0.5 flex items-center"> + <ListTree size={14} className="mr-1 text-gray-500" /> + Categories: + </h5> + <div className="flex flex-wrap gap-1"> + {subSense.categories.map((cat, idx) => ( + <span + key={idx} + className="text-xs bg-gray-100 text-gray-700 px-1.5 py-0.5 rounded-full" + > + {cat} + </span> + ))} + </div> + </div> + )} + + <ExampleDisplay examples={subSense.examples || []} /> + <RelatedTermsDisplay terms={subSense.synonyms} type="Synonyms" /> + + {subSense.tags && subSense.tags.length > 0 && ( + <div className="mt-2"> + <h5 className="text-xs font-semibold text-gray-600 mb-0.5 flex items-center"> + <Tags size={14} className="mr-1 text-gray-500" /> + Tags: + </h5> + <div className="flex flex-wrap gap-1"> + {subSense.tags.map((tag, idx) => ( + <span + key={idx} + className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded-full" + > + {tag} + </span> + ))} + </div> + </div> + )} + + {subSense.links && subSense.links.length > 0 && ( + <div className="mt-2"> + {subSense.links.map(([type, target], linkIdx) => ( + <a + key={linkIdx} + href={target} // Assuming target is a full URL or a path + target="_blank" + rel="noopener noreferrer" + className="text-xs text-blue-500 hover:text-blue-700 hover:underline mr-2 inline-flex items-center" + > + <LinkIcon size={12} className="mr-1" /> {type} + </a> + ))} + </div> + )} + </div> + ); +}; + +// Component for individual sense +const SenseCard = ({ + senseData, + senseNumber, +}: { + senseData: Sense; + senseNumber: number; +}) => { + return ( + <div className="mb-6 p-4 border border-gray-200 rounded-lg shadow-sm bg-white"> + <div className="flex justify-between items-center mb-2"> + <h3 className="text-xl font-semibold text-indigo-700"> + {senseNumber}. {NLP.unpackPos(senseData.pos)} + </h3> + </div> + + {senseData.etymology && ( + <details className="mb-3 group"> + <summary className="cursor-pointer flex items-center text-sm text-gray-600 hover:text-indigo-600 transition-colors list-none"> + Etymology + <ChevronDown size={16} className="ml-1 group-open:hidden" /> + <ChevronUp size={16} className="ml-1 hidden group-open:inline" /> + </summary> + <p className="mt-1 text-xs text-gray-500 italic bg-gray-50 p-2 rounded"> + {senseData.etymology} + </p> + </details> + )} + + {senseData.forms && senseData.forms.length > 0 && ( + <div className="mb-3"> + <h4 className="text-sm font-medium text-gray-700">Forms:</h4> + <div className="flex flex-wrap gap-2 mt-1"> + {senseData.forms.map((form, idx) => ( + <span + key={idx} + className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded-full" + > + {form.form}{" "} + {form.tags.length > 0 && `(${form.tags.join(", ")})`} + </span> + ))} + </div> + </div> + )} + + {senseData.senses.map((subSense, idx) => ( + <SubSenseDisplay + key={idx} + subSense={subSense} + subSenseNumber={senseNumber} + /> + ))} + + {senseData.related && ( + <div className="mt-3 pt-3 border-t border-gray-100"> + <h4 className="text-sm font-medium text-gray-700 mb-1 flex items-center"> + <Lightbulb size={16} className="mr-1 text-gray-500" /> + Related Terms: + </h4> + <RelatedTermsDisplay + terms={senseData.related.related} + type="Related" + /> + <RelatedTermsDisplay + terms={senseData.related.synonyms} + type="Synonyms (POS)" + /> + <RelatedTermsDisplay + terms={senseData.related.antonyms} + type="Antonyms (POS)" + /> + <RelatedTermsDisplay + terms={senseData.related.derived} + type="Derived" + /> + </div> + )} + </div> + ); +}; diff --git a/src/components/prosody/ClientCard.tsx b/src/components/prosody/ClientCard.tsx new file mode 100644 index 0000000..795e3bf --- /dev/null +++ b/src/components/prosody/ClientCard.tsx @@ -0,0 +1,64 @@ +"use client"; +import { getOnsets } from "@/actions/prosody"; +import type { ProsodySyllable, ProsodyWord } from "@/lib/types/cards"; +import { useTransition, useState } from "react"; + +export async function SyllableBreakdown({ syl }: { syl: ProsodySyllable }) { + const [isPending, startTransition] = useTransition(); + const [data, setData] = useState<any[]>([]); + function showOnset(e: React.MouseEvent) { + e.stopPropagation(); + console.log(syl); + startTransition(async () => { + const data = await getOnsets(syl.onset); + setData(data); + }); + } + console.log({ isPending }); + return ( + <div> + <div>whole: {syl.ipa}</div> + <div + onClick={showOnset} + className="text-blue-500 hover:text-blue-700 hover:underline" + > + onset: {syl.onset} + </div> + <div + onClick={showOnset} + className="text-blue-500 hover:text-blue-700 hover:underline" + > + nucleus: {syl.nucleus} + </div> + <div + onClick={showOnset} + className="text-blue-500 hover:text-blue-700 hover:underline" + > + coda: {syl.coda} + </div> + <div + onClick={showOnset} + className="text-blue-500 hover:text-blue-700 hover:underline" + > + tone: {syl.tone} + </div> + <div + onClick={showOnset} + className="text-blue-500 hover:text-blue-700 hover:underline" + > + tonename: {syl.tonen} + </div> + {data.length > 0 && ( + <div> + {data.map((d) => ( + <div key={d.id}> + <div>{d.spelling}</div> + </div> + ))} + </div> + )} + </div> + ); +} + +// Component for displaying examples diff --git a/src/components/prosody/ServerCard.tsx b/src/components/prosody/ServerCard.tsx new file mode 100644 index 0000000..4867721 --- /dev/null +++ b/src/components/prosody/ServerCard.tsx @@ -0,0 +1,343 @@ +// This is a Server Component +import React, { Suspense, useTransition } from "react"; +import { NLP } from "sortug-ai"; +import { + BookOpen, + Volume2, + Link as LinkIcon, + ChevronDown, + ChevronUp, + Search, + Info, + MessageSquareQuote, + Tags, + ListTree, + Lightbulb, + BookmarkIcon, +} from "lucide-react"; +import { + Example, + SubSense, + RelatedEntry, + Sense, + WordData, +} from "@/zoom/logic/types"; +import { CardResponse, ProsodyWord } from "@/lib/types/cards"; +import { thaiData } from "@/lib/calls/nlp"; +import { getRandomHexColor } from "@/lib/utils"; +import { BookmarkIconito } from "../Flashcard/BookmarkButton"; +import SyllableCard from "../Flashcard/Syllable"; +import SyllableSpan from "../Flashcard/SyllableSpan"; +import { SyllableBreakdown } from "./ClientCard"; + +export async function CardFront({ data }: { data: ProsodyWord }) { + console.log("cardfront", data); + return ( + <div className="absolute w-full h-full bg-white dark:bg-slate-800 rounded-xl backface-hidden flex flex-col justify-center gap-8 items-center p-6"> + <Suspense + fallback={ + <p className="text-5xl cursor-pointer hover:text-blue-700 font-semibold text-slate-800 dark:text-slate-100 text-center"> + {data.spelling} + </p> + } + > + <p className="text-5xl cursor-pointer font-semibold text-slate-800 dark:text-slate-100 text-center"> + {data.syllables.map((syl, i) => ( + // <span + // key={syl + i} + // style={{ color: getRandomHexColor() }} + // className="m-1 hover:text-6xl" + // > + // {syl} + // </span> + <SyllableSpan + key={syl.spelling + i} + spelling={syl.spelling} + lang={data.lang} + /> + ))} + </p> + </Suspense> + <p>Word: {data.id}</p> + <IpaDisplay ipaEntries={[{ ipa: data.ipa }]} /> + </div> + ); +} + +// Helper component for IPA display +export const IpaDisplay = ({ + ipaEntries, +}: { + ipaEntries: Array<{ ipa: string; tags?: string[] }>; +}) => { + if (!ipaEntries || ipaEntries.length === 0) return null; + return ( + <div className="flex items-center space-x-2 flex-wrap"> + {ipaEntries.map((entry, index) => { + const tags = entry.tags ? entry.tags : []; + return ( + <span key={index} className="text-lg text-blue-600 font-serif"> + {entry.ipa}{" "} + {tags.length > 0 && ( + <span className="text-xs text-gray-500">({tags.join(", ")})</span> + )} + </span> + ); + })} + <button + className="p-1 text-blue-500 hover:text-blue-700 transition-colors" + title="Pronounce" + // onClick={() => { + // /* Pronunciation logic would be client-side or a server roundtrip for audio file. */ alert( + // "Pronunciation feature not implemented for server component.", + // ); + // }} + > + <Volume2 size={20} /> + </button> + </div> + ); +}; + +export async function CardBack({ data }: { data: ProsodyWord }) { + return ( + <div className="w-full h-full bg-slate-50 dark:bg-slate-700 rounded-xl backface-hidden rotate-y-180 flex flex-col justify-between items-center p-6 relative"> + <span className="text-lg text-slate-500 dark:text-slate-400 self-start"> + {data.syllables.map((syl, i) => ( + <SyllableBreakdown syl={syl} key={syl.spelling + i} /> + ))} + </span> + <p className="text-3xl md:text-4xl font-semibold text-slate-800 dark:text-slate-100 text-center"> + {data.notes || ""} + </p> + </div> + ); +} + +// Component for displaying examples +const ExampleDisplay = ({ examples }: { examples: Example[] }) => { + if (!examples || examples.length === 0) return null; + return ( + <div className="mt-2"> + <h5 className="text-xs font-semibold text-gray-600 mb-1 flex items-center"> + <MessageSquareQuote size={14} className="mr-1 text-gray-500" /> + Examples: + </h5> + <ul className="list-disc list-inside pl-2 space-y-1"> + {examples.map((ex, idx) => ( + <li key={idx} className="text-xs text-gray-600"> + <span className="italic">"{ex.text}"</span> + {ex.ref && ( + <span className="text-gray-400 text-xs"> ({ex.ref})</span> + )} + {ex.type !== "quote" && ( + <span className="ml-1 text-xs bg-sky-100 text-sky-700 px-1 rounded-sm"> + {ex.type} + </span> + )} + </li> + ))} + </ul> + </div> + ); +}; + +// Component for displaying related terms (synonyms, antonyms, etc.) +const RelatedTermsDisplay = ({ + terms, + type, +}: { + terms: RelatedEntry[] | undefined; + type: string; +}) => { + if (!terms || terms.length === 0) return null; + return ( + <div className="mt-1"> + <span className="text-xs font-semibold text-gray-500 capitalize"> + {type}:{" "} + </span> + {terms.map((term, idx) => ( + <React.Fragment key={idx}> + <a + href={`/search?q=${encodeURIComponent(term.word)}`} + className="text-xs text-blue-500 hover:text-blue-700 hover:underline" + > + {term.word} + </a> + {/*term.source && ( + <span className="text-xs text-gray-400"> ({term.source})</span> + )*/} + {idx < terms.length - 1 && ", "} + </React.Fragment> + ))} + </div> + ); +}; + +// Component for displaying a SubSense +const SubSenseDisplay = ({ + subSense, + subSenseNumber, +}: { + subSense: SubSense; + subSenseNumber: number; +}) => { + return ( + <div className="mb-3 pl-4 border-l-2 border-indigo-200"> + {subSense.glosses.map((gloss, glossIdx) => ( + <p key={glossIdx} className="text-gray-700 mb-1"> + <span className="font-semibold"> + {subSenseNumber}.{glossIdx + 1} + </span>{" "} + {gloss} + </p> + ))} + {subSense.raw_glosses && + subSense.raw_glosses.length > 0 && + subSense.raw_glosses.join("") !== subSense.glosses.join("") && ( + <p className="text-xs text-gray-500 italic mb-1"> + (Raw: {subSense.raw_glosses.join("; ")}) + </p> + )} + + {subSense.categories && subSense.categories.length > 0 && ( + <div className="mt-1 mb-2"> + <h5 className="text-xs font-semibold text-gray-600 mb-0.5 flex items-center"> + <ListTree size={14} className="mr-1 text-gray-500" /> + Categories: + </h5> + <div className="flex flex-wrap gap-1"> + {subSense.categories.map((cat, idx) => ( + <span + key={idx} + className="text-xs bg-gray-100 text-gray-700 px-1.5 py-0.5 rounded-full" + > + {cat} + </span> + ))} + </div> + </div> + )} + + <ExampleDisplay examples={subSense.examples || []} /> + <RelatedTermsDisplay terms={subSense.synonyms} type="Synonyms" /> + + {subSense.tags && subSense.tags.length > 0 && ( + <div className="mt-2"> + <h5 className="text-xs font-semibold text-gray-600 mb-0.5 flex items-center"> + <Tags size={14} className="mr-1 text-gray-500" /> + Tags: + </h5> + <div className="flex flex-wrap gap-1"> + {subSense.tags.map((tag, idx) => ( + <span + key={idx} + className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded-full" + > + {tag} + </span> + ))} + </div> + </div> + )} + + {subSense.links && subSense.links.length > 0 && ( + <div className="mt-2"> + {subSense.links.map(([type, target], linkIdx) => ( + <a + key={linkIdx} + href={target} // Assuming target is a full URL or a path + target="_blank" + rel="noopener noreferrer" + className="text-xs text-blue-500 hover:text-blue-700 hover:underline mr-2 inline-flex items-center" + > + <LinkIcon size={12} className="mr-1" /> {type} + </a> + ))} + </div> + )} + </div> + ); +}; + +// Component for individual sense +const SenseCard = ({ + senseData, + senseNumber, +}: { + senseData: Sense; + senseNumber: number; +}) => { + return ( + <div className="mb-6 p-4 border border-gray-200 rounded-lg shadow-sm bg-white"> + <div className="flex justify-between items-center mb-2"> + <h3 className="text-xl font-semibold text-indigo-700"> + {senseNumber}. {NLP.unpackPos(senseData.pos)} + </h3> + </div> + + {senseData.etymology && ( + <details className="mb-3 group"> + <summary className="cursor-pointer flex items-center text-sm text-gray-600 hover:text-indigo-600 transition-colors list-none"> + Etymology + <ChevronDown size={16} className="ml-1 group-open:hidden" /> + <ChevronUp size={16} className="ml-1 hidden group-open:inline" /> + </summary> + <p className="mt-1 text-xs text-gray-500 italic bg-gray-50 p-2 rounded"> + {senseData.etymology} + </p> + </details> + )} + + {senseData.forms && senseData.forms.length > 0 && ( + <div className="mb-3"> + <h4 className="text-sm font-medium text-gray-700">Forms:</h4> + <div className="flex flex-wrap gap-2 mt-1"> + {senseData.forms.map((form, idx) => ( + <span + key={idx} + className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded-full" + > + {form.form}{" "} + {form.tags.length > 0 && `(${form.tags.join(", ")})`} + </span> + ))} + </div> + </div> + )} + + {senseData.senses.map((subSense, idx) => ( + <SubSenseDisplay + key={idx} + subSense={subSense} + subSenseNumber={senseNumber} + /> + ))} + + {senseData.related && ( + <div className="mt-3 pt-3 border-t border-gray-100"> + <h4 className="text-sm font-medium text-gray-700 mb-1 flex items-center"> + <Lightbulb size={16} className="mr-1 text-gray-500" /> + Related Terms: + </h4> + <RelatedTermsDisplay + terms={senseData.related.related} + type="Related" + /> + <RelatedTermsDisplay + terms={senseData.related.synonyms} + type="Synonyms (POS)" + /> + <RelatedTermsDisplay + terms={senseData.related.antonyms} + type="Antonyms (POS)" + /> + <RelatedTermsDisplay + terms={senseData.related.derived} + type="Derived" + /> + </div> + )} + </div> + ); +}; |