summaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
authorpolwex <polwex@sortug.com>2025-06-03 09:34:29 +0700
committerpolwex <polwex@sortug.com>2025-06-03 09:34:29 +0700
commit2401217a4019938d1c1cc61b6e33ccb233eb6e74 (patch)
tree06118284965be5cfd6b417dca86d46db5758217b /src/components
parent2b80f7950df34f2a160135d7e20220a9b2ec3352 (diff)
this is golden thanks claude
Diffstat (limited to 'src/components')
-rw-r--r--src/components/Flashcard/Deck3.tsx223
-rw-r--r--src/components/Flashcard/ServerCard2.tsx359
-rw-r--r--src/components/prosody/ClientCard.tsx64
-rw-r--r--src/components/prosody/ServerCard.tsx343
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>
+ );
+};