summaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
authorpolwex <polwex@sortug.com>2025-05-29 12:10:22 +0700
committerpolwex <polwex@sortug.com>2025-05-29 12:10:22 +0700
commita3f24ea79b14394b24c4b60a010651eb29eeb872 (patch)
treecb1c4937084116f66a59727ee752afd974714c8e /src/components
parent7abf2227438362ad30820ee236405ec1b57a40b6 (diff)
glorious new db
Diffstat (limited to 'src/components')
-rw-r--r--src/components/Flashcard/ServerCard.tsx43
-rw-r--r--src/components/Flashcard/Syllable.tsx44
-rw-r--r--src/components/Flashcard/SyllableModal.tsx110
-rw-r--r--src/components/Flashcard/SyllableSpan.tsx45
-rw-r--r--src/components/lang/ThaiPhonology.tsx250
-rw-r--r--src/components/tones/ToneSelectorClient.tsx199
-rw-r--r--src/components/ui/skeleton.tsx13
7 files changed, 692 insertions, 12 deletions
diff --git a/src/components/Flashcard/ServerCard.tsx b/src/components/Flashcard/ServerCard.tsx
index d377dce..df37ba8 100644
--- a/src/components/Flashcard/ServerCard.tsx
+++ b/src/components/Flashcard/ServerCard.tsx
@@ -23,13 +23,21 @@ import {
WordData,
} from "@/zoom/logic/types";
import { CardResponse } from "@/lib/types/cards";
-import { thaiData } from "@/pages/api/nlp";
+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: CardResponse }) {
+export async function CardFront({
+ data,
+ needFetch = true,
+}: {
+ data: CardResponse;
+ needFetch?: boolean;
+}) {
// const extraData = data.expression.lang
- const extraData = await thaiData(data.expression.spelling);
+ const extraData = needFetch ? await thaiData(data.expression.spelling) : [];
// console.log({ extraData });
return (
@@ -42,15 +50,26 @@ export async function CardFront({ data }: { data: CardResponse }) {
}
>
<p className="text-5xl cursor-pointer font-semibold text-slate-800 dark:text-slate-100 text-center">
- {extraData[0]?.syllables.map((syl, i) => (
- <span
- key={syl + i}
- style={{ color: getRandomHexColor() }}
- className="m-1 hover:text-6xl"
- >
- {syl}
- </span>
- ))}
+ {needFetch ? (
+ extraData[0]?.syllables.map((syl, i) => (
+ // <span
+ // key={syl + i}
+ // style={{ color: getRandomHexColor() }}
+ // className="m-1 hover:text-6xl"
+ // >
+ // {syl}
+ // </span>
+ <SyllableSpan
+ key={syl + i}
+ spelling={syl}
+ lang={data.expression.lang}
+ />
+ ))
+ ) : (
+ <p className="text-5xl cursor-pointer hover:text-blue-700 font-semibold text-slate-800 dark:text-slate-100 text-center">
+ {data.expression.spelling}
+ </p>
+ )}
</p>
</Suspense>
<IpaDisplay ipaEntries={data.expression.ipa} />
diff --git a/src/components/Flashcard/Syllable.tsx b/src/components/Flashcard/Syllable.tsx
new file mode 100644
index 0000000..e470a4b
--- /dev/null
+++ b/src/components/Flashcard/Syllable.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { syllableAction, thaiAnalysis } from "@/actions/lang";
+import { CardResponse } from "@/lib/types/cards";
+import { ReactNode, useState, useTransition } from "react";
+import { Spinner } from "../ui/spinner";
+import Modal from "@/components/Modal";
+import { getRandomHexColor } from "@/lib/utils";
+
+const SyllableCard: React.FC<{ data: CardResponse }> = ({ 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">
+ <p className="text-5xl cursor-pointer hover:text-blue-700 font-semibold text-slate-800 dark:text-slate-100 text-center">
+ {data.expression.spelling}
+ </p>
+ <IpaDisplay ipaEntries={data.expression.ipa} />
+ </div>
+ );
+};
+
+export default SyllableCard;
+
+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>
+ );
+ })}
+ </div>
+ );
+};
diff --git a/src/components/Flashcard/SyllableModal.tsx b/src/components/Flashcard/SyllableModal.tsx
new file mode 100644
index 0000000..a00fd10
--- /dev/null
+++ b/src/components/Flashcard/SyllableModal.tsx
@@ -0,0 +1,110 @@
+// This is a Server Component
+import React from "react";
+import db from "@/lib/db";
+import {
+ Card,
+ CardHeader,
+ CardDescription,
+ CardContent,
+ CardFooter,
+ CardTitle,
+} from "@/components/ui/card";
+import { NLP } from "sortug-ai";
+import {
+ BookOpen,
+ Volume2,
+ Link as LinkIcon,
+ ChevronDown,
+ ChevronUp,
+ Search,
+ Info,
+ MessageSquareQuote,
+ Tags,
+ ListTree,
+ Lightbulb,
+} from "lucide-react";
+import {
+ Example,
+ SubSense,
+ RelatedEntry,
+ Sense,
+ WordData,
+} from "@/zoom/logic/types";
+import { isTonal } from "@/lib/lang/utils";
+
+type WordProps = { text: string; lang: string };
+export default async function (props: WordProps) {
+ const { text, lang } = props;
+ const data = db.fetchWordBySpelling(text, lang);
+
+ if (!data) return <p>oh...</p>;
+ console.log(data.senses[0]);
+ return (
+ <Card className="overflow-y-scroll max-h-[80vh]">
+ <CardHeader>
+ <CardTitle>
+ <h1 className="text-5xl">{text}</h1>
+ </CardTitle>
+ <CardDescription>
+ <IpaDisplay ipaEntries={data.ipa} />
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ {isTonal(text) ? <Tones {...props} /> : <NotTones {...props} />}
+ </CardContent>
+ <CardFooter></CardFooter>
+ </Card>
+ );
+ // return (
+ // <div className="p-6">
+ // <h3 className="mb-2 text-2xl font-bold">{word}</h3>
+ // <p className="mb-1 text-xl text-green-600">${word.}</p>
+ // <p className="text-gray-700">{word}</p>
+ // <p className="mt-4 text-xs text-gray-500">
+ // Content rendered on the server at: {new Date().toLocaleTimeString()}
+ // </p>
+ // </div>
+ // );
+}
+
+// Helper component for IPA display
+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>
+ );
+};
+
+function Tones({ text, lang }: WordProps) {
+ return <div></div>;
+}
+function NotTones({ text, lang }: WordProps) {
+ return <div></div>;
+}
diff --git a/src/components/Flashcard/SyllableSpan.tsx b/src/components/Flashcard/SyllableSpan.tsx
new file mode 100644
index 0000000..445895e
--- /dev/null
+++ b/src/components/Flashcard/SyllableSpan.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { syllableAction, thaiAnalysis } from "@/actions/lang";
+import { CardResponse } from "@/lib/types/cards";
+import { ReactNode, useState, useTransition } from "react";
+import { Spinner } from "../ui/spinner";
+import Modal from "@/components/Modal";
+import { getRandomHexColor } from "@/lib/utils";
+
+const SyllableSpan: React.FC<{ spelling: string; lang: string }> = ({
+ spelling,
+ lang,
+}) => {
+ const [modalContent, setModalContent] = useState<ReactNode | null>(null);
+
+ const closeModal = () => setModalContent(null);
+
+ const [isPending, startTransition] = useTransition();
+ const handleClick = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ startTransition(async () => {
+ const modal = await syllableAction(spelling, lang);
+ setModalContent(modal);
+ });
+ };
+
+ return (
+ <>
+ <span
+ onClick={handleClick}
+ className="m-1 hover:text-6xl"
+ style={{ color: getRandomHexColor() }}
+ >
+ {spelling}
+ </span>
+ {modalContent && (
+ <Modal onClose={closeModal} isOpen={!!modalContent}>
+ {modalContent}
+ </Modal>
+ )}
+ </>
+ );
+};
+
+export default SyllableSpan;
diff --git a/src/components/lang/ThaiPhonology.tsx b/src/components/lang/ThaiPhonology.tsx
new file mode 100644
index 0000000..199d0b8
--- /dev/null
+++ b/src/components/lang/ThaiPhonology.tsx
@@ -0,0 +1,250 @@
+// import React from "react";
+
+// /**
+// * ThaiConsonantGrid – a visual table of Thai consonants modelled after the
+// * traditional Sanskrit‑style chart. Each cell shows the Thai glyph and its
+// * IPA. Rows are places of articulation, columns are manners. Colours follow
+// * the pedagogical convention from the reference screenshot.
+// */
+// export default function ThaiConsonantGrid() {
+// /** Column headers in display order */
+// const cols = [
+// "stopped",
+// "aspirated",
+// "voiced",
+// "voiced‑aspirated",
+// "nasal",
+// "semiVowel",
+// "sibilant",
+// "H‑aspirate",
+// "throatBase",
+// "others",
+// ] as const;
+
+// /** Row headers in display order */
+// const rows = [
+// "Deep Throat",
+// "guttural",
+// "palatal",
+// "cerebral",
+// "dental",
+// "labial",
+// ] as const;
+// type ArticulationPoint =
+// | "bilabial"
+// | "labiodental"
+// | "dental"
+// | "alveolar"
+// | "postalveolar"
+// | "retroflex"
+// | "palatal"
+// | "velar"
+// | "uvular"
+// | "pharyngeal"
+// | "glottal";
+// type ArticulationMode =
+// | "plosive"
+// | "nasal"
+// | "trill"
+// | "flap"
+// | "fricative"
+// | "affricate"
+// | "lateral fricative"
+// | "approximant"
+// | "lateral approximant";
+// type Voicing = "unvoiced" | "voiced" | "aspirated" | "voiced aspirated";
+// type VowelHeight = "high" | "close-mid" | "open-mid" | "open";
+// type VowelFront = "front" | "central" | "back";
+// type VowelRound = "rounded" | "unrounded";
+
+// /**
+// * Minimal description for each consonant we want to render. Position is
+// * given by its (rowIdx, colIdx). The colour is a Tailwind background class
+// * so you can tweak the palette in one place.
+// */
+// interface Cell {
+// row: number; // 0‑based index into rows
+// col: number; // 0‑based index into cols
+// glyph: string;
+// ipa: string;
+// colour: string; // Tailwind bg‑* class
+// highlight?: boolean; // optional neon border
+// class: "high" | "mid" | "low";
+// }
+
+// const cells: Cell[] = [
+// // ───────────────────── guttural row (index 1) ──────────────────────
+// {
+// row: 1,
+// col: 0,
+// glyph: "ก",
+// class: "high",
+// ipa: "/k/",
+// colour: "bg-sky-500",
+// },
+// {
+// row: 1,
+// col: 1,
+// glyph: "ข",
+// class: "high",
+// ipa: "/kʰ/",
+// colour: "bg-sky-500",
+// },
+// {
+// row: 1,
+// col: 1,
+// glyph: "ฃ",
+// class: "high",
+// ipa: "/kʰ/",
+// colour: "bg-sky-500",
+// },
+// {
+// row: 1,
+// col: 2,
+// glyph: "ค",
+// class: "high",
+// ipa: "/kʰ/",
+// colour: "bg-sky-500",
+// },
+// {
+// row: 1,
+// col: 2,
+// glyph: "ฅ",
+// class: "high",
+// ipa: "/kʰ/",
+// colour: "bg-sky-500",
+// },
+// {
+// row: 1,
+// col: 2,
+// glyph: "ฆ",
+// class: "high",
+// ipa: "/kʰ/",
+// colour: "bg-sky-500",
+// },
+// {
+// row: 1,
+// col: 4,
+// glyph: "ง",
+// ipa: "/ŋ/",
+// colour: "bg-sky-500",
+// highlight: true,
+// },
+
+// // ───────────────────── palatal row (index 2) ───────────────────────
+// { row: 2, col: 0, glyph: "จ", ipa: "/tɕ/", colour: "bg-pink-500" },
+// { row: 2, col: 1, glyph: "ฉ", ipa: "/tɕʰ/", colour: "bg-pink-500" },
+// { row: 2, col: 2, glyph: "ช", ipa: "/tɕʰ/", colour: "bg-pink-500" },
+// { row: 2, col: 2, glyph: "ซ", ipa: "/s/", colour: "bg-pink-500" },
+// { row: 2, col: 3, glyph: "ฌ", ipa: "/tɕʰ/", colour: "bg-pink-500" },
+// { row: 2, col: 5, glyph: "ญ", ipa: "/j/", colour: "bg-pink-500" },
+
+// // ───────────────────── cerebral row (index 3) ──────────────────────
+// { row: 3, col: 0, glyph: "ฎ", ipa: "/d/", colour: "bg-emerald-700" },
+// { row: 3, col: 0, glyph: "ฐ", ipa: "/t/", colour: "bg-emerald-700" },
+// { row: 3, col: 1, glyph: "ฏ", ipa: "/tʰ/", colour: "bg-emerald-700" },
+// {
+// row: 3,
+// col: 4,
+// glyph: "ฑ",
+// ipa: "/tʰ or d/",
+// colour: "bg-emerald-700",
+// highlight: true,
+// },
+// { row: 3, col: 3, glyph: "ฒ", ipa: "/tʰ/", colour: "bg-emerald-700" },
+// { row: 3, col: 4, glyph: "ณ", ipa: "/n/", colour: "bg-emerald-700" },
+// { row: 3, col: 5, glyph: "ศ", ipa: "/s/", colour: "bg-emerald-700" },
+// { row: 3, col: 5, glyph: "ษ", ipa: "/s/", colour: "bg-emerald-700" },
+
+// // ───────────────────── dental row (index 4) ────────────────────────
+// { row: 4, col: 0, glyph: "ต", ipa: "/d/", colour: "bg-emerald-600" },
+// { row: 4, col: 0, glyph: "ถ", ipa: "/t/", colour: "bg-emerald-600" },
+// { row: 4, col: 1, glyph: "ท", ipa: "/tʰ/", colour: "bg-emerald-600" },
+// { row: 4, col: 2, glyph: "ธ", ipa: "/tʰ/", colour: "bg-emerald-600" },
+// { row: 4, col: 4, glyph: "น", ipa: "/n/", colour: "bg-emerald-600" },
+// { row: 4, col: 6, glyph: "ส", ipa: "/s/", colour: "bg-emerald-600" },
+
+// // ───────────────────── labial row (index 5) ────────────────────────
+// { row: 5, col: 0, glyph: "บ", ipa: "/b/", colour: "bg-orange-500" },
+// { row: 5, col: 0, glyph: "ป", ipa: "/p/", colour: "bg-orange-500" },
+// { row: 5, col: 1, glyph: "ผ", ipa: "/pʰ/", colour: "bg-orange-500" },
+// { row: 5, col: 2, glyph: "พ", ipa: "/pʰ/", colour: "bg-orange-500" },
+// { row: 5, col: 2, glyph: "ฟ", ipa: "/f/", colour: "bg-orange-500" },
+// { row: 5, col: 3, glyph: "ภ", ipa: "/pʰ/", colour: "bg-orange-500" },
+// { row: 5, col: 4, glyph: "ม", ipa: "/m/", colour: "bg-orange-500" },
+// {
+// row: 5,
+// col: 9,
+// glyph: "ฟฬ",
+// ipa: "/l/",
+// colour: "bg-emerald-600",
+// highlight: true,
+// },
+
+// // ───────────────────── extra column (index^?) – throat + others ─────
+// { row: 1, col: 7, glyph: "ห", ipa: "/h/", colour: "bg-gray-400" },
+// { row: 1, col: 8, glyph: "อ", ipa: "/ʔ/", colour: "bg-gray-400" },
+// ];
+
+// return (
+// <div className="overflow-x-auto p-4">
+// {/* Column header */}
+// <div
+// className="grid"
+// style={{
+// gridTemplateColumns: `auto repeat(${cols.length}, minmax(4rem, 1fr))`,
+// }}
+// >
+// {/* top‑left empty cell */}
+// <div />
+// {cols.map((c) => (
+// <div
+// key={c}
+// className="bg-neutral-800 text-amber-300 text-center uppercase py-2 text-sm font-semibold border border-neutral-700"
+// >
+// {c}
+// </div>
+// ))}
+
+// {/* rows */}
+// {rows.map((rowLabel, ri) => (
+// <React.Fragment key={rowLabel}>
+// {/* row header */}
+// <div className="bg-neutral-900 text-amber-300 flex items-center justify-center px-2 py-1 text-xs font-bold whitespace-nowrap border border-neutral-700">
+// {rowLabel}
+// </div>
+// {/* cells within the row */}
+// {cols.map((_, ci) => {
+// // We may have multiple consonants per slot; gather them.
+// const here = cells.filter((c) => c.row === ri && c.col === ci);
+// if (here.length === 0)
+// return <div key={ci} className="border border-neutral-700" />;
+
+// return (
+// <div
+// key={ci}
+// className={[
+// "border border-neutral-700 rounded-md flex flex-col items-center justify-center gap-1 p-1 text-white",
+// here[0].colour,
+// here.some((c) => c.highlight)
+// ? "ring-2 ring-green-400"
+// : "",
+// ].join(" ")}
+// >
+// {here.map((c, i) => (
+// <span key={i} className="text-sm leading-tight text-center">
+// <span className="block text-lg font-semibold">
+// {c.glyph}
+// </span>
+// <span className="block text-xs">{c.ipa}</span>
+// </span>
+// ))}
+// </div>
+// );
+// })}
+// </React.Fragment>
+// ))}
+// </div>
+// </div>
+// );
+// }
diff --git a/src/components/tones/ToneSelectorClient.tsx b/src/components/tones/ToneSelectorClient.tsx
new file mode 100644
index 0000000..0ee9433
--- /dev/null
+++ b/src/components/tones/ToneSelectorClient.tsx
@@ -0,0 +1,199 @@
+'use client';
+
+import { useState, useEffect, useTransition } from 'react';
+import { WordData } from '@/zoom/logic/types';
+import { fetchWordsByToneAndSyllables } from '@/actions/tones';
+import { Button } from '@/components/ui/button';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
+import { Label } from '@/components/ui/label';
+import { Skeleton } from '@/components/ui/skeleton'; // For loading state
+
+// Helper to display tones prominently
+const ProminentToneDisplay = ({ wordData }: { wordData: WordData }) => {
+ if (!wordData.prosody || !Array.isArray(wordData.prosody)) {
+ return <p className="text-gray-500">No prosody data</p>;
+ }
+
+ return (
+ <div className="flex flex-col items-center mb-4">
+ <h1 className="text-6xl font-bold text-blue-600 mb-2">{wordData.spelling}</h1>
+ <div className="flex space-x-4">
+ {wordData.prosody.map((p, index) => (
+ <div key={index} className="text-center">
+ <p className="text-sm text-gray-500">Syllable {index + 1}</p>
+ <p className="text-5xl font-semibold text-indigo-500">{p.tone ?? '?'}</p>
+ </div>
+ ))}
+ </div>
+ {wordData.ipa && wordData.ipa.length > 0 && (
+ <p className="text-xl text-gray-700 mt-2">
+ {wordData.ipa.map(i => i.ipa).join(' / ')}
+ </p>
+ )}
+ </div>
+ );
+};
+
+
+export default function ToneSelectorClient({ initialWord }: { initialWord: WordData | null }) {
+ const [currentWord, setCurrentWord] = useState<WordData | null>(initialWord);
+ const [syllableCount, setSyllableCount] = useState<number>(initialWord?.syllables || 1);
+ const [selectedTones, setSelectedTones] = useState<(number | null)[]>(
+ initialWord?.prosody?.map(p => p.tone ?? null) || [null]
+ );
+ const [isLoading, startTransition] = useTransition();
+
+ useEffect(() => {
+ // Adjust selectedTones array length when syllableCount changes
+ setSelectedTones(prevTones => {
+ const newTones = Array(syllableCount).fill(null);
+ for (let i = 0; i < Math.min(prevTones.length, syllableCount); i++) {
+ newTones[i] = prevTones[i];
+ }
+ return newTones;
+ });
+ }, [syllableCount]);
+
+ const handleFetchWord = () => {
+ startTransition(async () => {
+ const word = await fetchWordsByToneAndSyllables(syllableCount, selectedTones);
+ setCurrentWord(word);
+ });
+ };
+
+ const handleSyllableCountChange = (value: string) => {
+ const count = parseInt(value, 10);
+ if (!isNaN(count) && count > 0 && count <= 5) { // Max 5 syllables for simplicity
+ setSyllableCount(count);
+ }
+ };
+
+ const handleToneChange = (syllableIndex: number, value: string) => {
+ const tone = value === 'any' ? null : parseInt(value, 10);
+ setSelectedTones(prevTones => {
+ const newTones = [...prevTones];
+ newTones[syllableIndex] = tone;
+ return newTones;
+ });
+ };
+
+ const thaiTones = [
+ { value: '1', label: '1 (Mid)' },
+ { value: '2', label: '2 (Low)' },
+ { value: '3', label: '3 (Falling)' },
+ { value: '4', label: '4 (High)' },
+ { value: '5', label: '5 (Rising)' },
+ ];
+
+ return (
+ <div className="container mx-auto p-4 max-w-2xl">
+ <Card className="mb-6">
+ <CardHeader>
+ <CardTitle>Thai Tone Explorer</CardTitle>
+ <CardDescription>Select syllable count and tones to find Thai words.</CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div>
+ <Label htmlFor="syllable-count" className="text-lg font-medium">Number of Syllables</Label>
+ <Select
+ value={syllableCount.toString()}
+ onValueChange={handleSyllableCountChange}
+ >
+ <SelectTrigger id="syllable-count" className="w-full md:w-1/2 mt-1">
+ <SelectValue placeholder="Select number of syllables" />
+ </SelectTrigger>
+ <SelectContent>
+ {[1, 2, 3, 4, 5].map(num => (
+ <SelectItem key={num} value={num.toString()}>
+ {num} Syllable{num > 1 ? 's' : ''}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {Array.from({ length: syllableCount }).map((_, index) => (
+ <div key={index}>
+ <Label htmlFor={`tone-select-${index}`} className="text-lg font-medium">
+ Tone for Syllable {index + 1}
+ </Label>
+ <Select
+ value={selectedTones[index]?.toString() || 'any'}
+ onValueChange={(value) => handleToneChange(index, value)}
+ >
+ <SelectTrigger id={`tone-select-${index}`} className="w-full md:w-1/2 mt-1">
+ <SelectValue placeholder={`Select tone for syllable ${index + 1}`} />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="any">Any Tone</SelectItem>
+ {thaiTones.map(tone => (
+ <SelectItem key={tone.value} value={tone.value}>
+ {tone.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ ))}
+ </CardContent>
+ <CardFooter>
+ <Button onClick={handleFetchWord} disabled={isLoading} className="w-full md:w-auto">
+ {isLoading ? 'Searching...' : 'Find Word'}
+ </Button>
+ </CardFooter>
+ </Card>
+
+ {isLoading && !currentWord && (
+ <Card>
+ <CardHeader><Skeleton className="h-12 w-3/4" /></CardHeader>
+ <CardContent className="space-y-4">
+ <Skeleton className="h-8 w-1/2" />
+ <Skeleton className="h-20 w-full" />
+ <Skeleton className="h-6 w-full" />
+ </CardContent>
+ </Card>
+ )}
+
+ {!isLoading && currentWord && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-center">Current Word</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <ProminentToneDisplay wordData={currentWord} />
+ {/* You can add more details from WordData here if needed, like definitions */}
+ {currentWord.senses && currentWord.senses.length > 0 && (
+ <div className="mt-4 pt-4 border-t">
+ <h3 className="text-lg font-semibold mb-2">Meanings:</h3>
+ {currentWord.senses.map((sense, sIdx) => (
+ <div key={sIdx} className="mb-2 p-2 border rounded bg-gray-50">
+ <p className="font-medium text-indigo-600">{sense.pos}</p>
+ {sense.senses && Array.isArray(sense.senses) && sense.senses.map((subSense, ssIdx) => (
+ subSense.glosses && Array.isArray(subSense.glosses) && subSense.glosses.map((gloss: string, gIdx: number) => (
+ <p key={`${ssIdx}-${gIdx}`} className="text-sm text-gray-700 ml-2">- {gloss}</p>
+ ))
+ ))}
+ </div>
+ ))}
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ )}
+
+ {!isLoading && !currentWord && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-center">No Word Found</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <p className="text-center text-gray-600">
+ Could not find a Thai word matching your criteria. Try different selections.
+ </p>
+ </CardContent>
+ </Card>
+ )}
+ </div>
+ );
+}
diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..32ea0ef
--- /dev/null
+++ b/src/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="skeleton"
+ className={cn("bg-accent animate-pulse rounded-md", className)}
+ {...props}
+ />
+ )
+}
+
+export { Skeleton }