diff options
-rw-r--r-- | src/actions/lang.ts | 7 | ||||
-rw-r--r-- | src/components/Flashcard/Card.tsx | 16 | ||||
-rw-r--r-- | src/components/Flashcard/Deck2.tsx | 205 | ||||
-rw-r--r-- | src/components/Flashcard/ServerCard.tsx | 345 | ||||
-rw-r--r-- | src/lib/db/schema.sql | 8 | ||||
-rw-r--r-- | src/lib/utils.ts | 8 | ||||
-rw-r--r-- | src/pages/api/nlp.ts | 38 | ||||
-rw-r--r-- | src/pages/lesson/[slug].tsx | 9 |
8 files changed, 634 insertions, 2 deletions
diff --git a/src/actions/lang.ts b/src/actions/lang.ts index fc798da..b38b542 100644 --- a/src/actions/lang.ts +++ b/src/actions/lang.ts @@ -2,6 +2,7 @@ import { AsyncRes } from "@/lib/types"; import { NLP } from "sortug-ai"; import ServerWord from "@/zoom/ServerWord"; +import { analyzeTHWord, segmentateThai } from "@/pages/api/nlp"; // import db from "../lib/db"; export async function wordAction( @@ -12,6 +13,12 @@ export async function wordAction( return ServerWord({ word: text, lang }); } +export async function thaiAnalysis(text: string) { + const res = await segmentateThai(text); + const res2 = await analyzeTHWord(text); + console.log({ res, res2 }); +} + // export async function ocrAction(file: File): AsyncRes<string[]> { // const res = await NLP.ocr(file); // return res; diff --git a/src/components/Flashcard/Card.tsx b/src/components/Flashcard/Card.tsx index 7cada24..9eccdb5 100644 --- a/src/components/Flashcard/Card.tsx +++ b/src/components/Flashcard/Card.tsx @@ -1,6 +1,9 @@ "use client"; +import { thaiAnalysis } from "@/actions/lang"; import { CardResponse } from "@/lib/types/cards"; +import { useTransition } from "react"; +import { Spinner } from "../ui/spinner"; // export default function ({ user }: { user: { name: string; id: number } }) { // const [state, formAction, isPending] = useActionState(postLogout, 0); @@ -58,6 +61,13 @@ const Flashcard: React.FC<FlashcardProps> = ({ } }; + const [isPending, startTransition] = useTransition(); + const handleClick = () => { + startTransition(async () => { + const res = await thaiAnalysis(data.expression.spelling); + }); + }; + return ( <div className={`w-full max-w-md h-80 perspective group ${getAnimationClass()}`} @@ -77,9 +87,13 @@ const Flashcard: React.FC<FlashcardProps> = ({ </span> ))} </span> - <p className="text-3xl md:text-4xl font-semibold text-slate-800 dark:text-slate-100 text-center"> + <p + onClick={handleClick} + className="text-3xl cursor-pointer hover:text-blue-700 md:text-4xl font-semibold text-slate-800 dark:text-slate-100 text-center" + > {data.expression.spelling} </p> + {isPending && <Spinner />} <div className="w-full h-6"> {" "} {/* Placeholder for spacing, mimics bottom controls */} diff --git a/src/components/Flashcard/Deck2.tsx b/src/components/Flashcard/Deck2.tsx new file mode 100644 index 0000000..4fd8740 --- /dev/null +++ b/src/components/Flashcard/Deck2.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { CardResponse, DeckResponse } from "@/lib/types/cards"; +import React, { ReactNode, useCallback, useEffect, useState } from "react"; +import { Button } from "../ui/button"; +import { ChevronLeftIcon, ChevronRightIcon, RotateCcwIcon } from "lucide-react"; +import "./cards.css"; +import Flashcard from "./Card"; + +type CardData = { + id: number; + front: ReactNode; + back: ReactNode; +}; +// --- Main App Component --- +function Deck({ data, cards }: { data: DeckResponse; 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); + }, 500); // Duration of enter animation + }, 500); // 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); + }, 500); // Duration of enter animation + }, 500); // 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]); + + 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"> + <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> + </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/ServerCard.tsx b/src/components/Flashcard/ServerCard.tsx new file mode 100644 index 0000000..75442b4 --- /dev/null +++ b/src/components/Flashcard/ServerCard.tsx @@ -0,0 +1,345 @@ +// 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 } from "@/lib/types/cards"; +import { thaiData } from "@/pages/api/nlp"; +import { getRandomHexColor } from "@/lib/utils"; + +export async function CardFront({ data }: { data: CardResponse }) { + // const extraData = data.expression.lang + const extraData = await thaiData(data.expression.spelling); + console.log({ extraData }); + + 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.expression.spelling} + </p> + } + > + <p className="text-5xl cursor-pointer font-semibold text-slate-800 dark:text-slate-100 text-center"> + {extraData[0]?.syllables.map((syl) => ( + <span + style={{ color: getRandomHexColor() }} + className="m-1 hover:text-6l" + > + {syl} + </span> + ))} + </p> + </Suspense> + <IpaDisplay ipaEntries={data.expression.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: CardResponse }) { + // <BookmarkIcon onClick={handleClick} className="absolute top-5 right-10" /> + return ( + <div className="absolute 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"> + <BookmarkIcon className="absolute top-5 right-10" /> + <span className="text-lg text-slate-500 dark:text-slate-400 self-start"> + {data.expression.senses.map((ss, i) => ( + <div key={`ss${i}`}> + {ss.senses.map((sss, i) => ( + <div key={`sss${i}`}> + {sss.glosses.map((ssss, i) => ( + <p key={ssss + i}>{ssss}</p> + ))} + </div> + ))} + </div> + ))} + </span> + <p className="text-3xl md:text-4xl font-semibold text-slate-800 dark:text-slate-100 text-center"> + {data.note} + </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/lib/db/schema.sql b/src/lib/db/schema.sql index 8d1b288..4506619 100644 --- a/src/lib/db/schema.sql +++ b/src/lib/db/schema.sql @@ -57,6 +57,14 @@ CREATE INDEX IF NOT EXISTS idx_words_pos ON senses(pos); CREATE INDEX IF NOT EXISTS idx_senses_parent ON senses(parent_id); +CREATE TABLE IF NOT EXISTS bookmarks( + word_id INTEGER PRIMARY KEY, + notes TEXT, + created INTEGER NOT NULL, + FOREIGN KEY (word_id) REFERENCES expressions(id) +); +CREATE INDEX IF NOT EXISTS idx_bookmarks ON bookmarks(word_id); + -- Categories table (for noun and verb categories) CREATE TABLE IF NOT EXISTS categories ( diff --git a/src/lib/utils.ts b/src/lib/utils.ts index d3fdf9c..9bc74b8 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -49,3 +49,11 @@ export function handlePromise<T>( if (settlement.status === "fulfilled") return settlement.value; else return `${settlement.reason}`; } + +export function getRandomHexColor() { + // Generate a random number and convert it to a hexadecimal string + const randomColor = Math.floor(Math.random() * 16777215).toString(16); + + // Ensure the color code is always 6 digits by padding with zeros if needed + return "#" + randomColor.padStart(6, "0"); +} diff --git a/src/pages/api/nlp.ts b/src/pages/api/nlp.ts index 0e5eacb..27c330d 100644 --- a/src/pages/api/nlp.ts +++ b/src/pages/api/nlp.ts @@ -30,3 +30,41 @@ export const POST = async (request: Request): Promise<Response> => { return Response.json({ message: "Failure" }, { status: 500 }); } }; + +type AnalyzeRes = { + word: string; + syllables: string[]; + ipa: string; + pos: string; +}; + +export async function thaiData(word: string): Promise<AnalyzeRes[]> { + const [head, tail] = await Promise.all([ + analyzeTHWord(word), + segmentateThai(word), + ]); + return [head, ...tail]; +} + +export async function analyzeTHWord(word: string): Promise<AnalyzeRes> { + const opts = { + method: "POST", + headers: { "Content-type": "application/json" }, + body: JSON.stringify({ word }), + }; + const r1 = await fetch("http://localhost:8001" + "/analyze", opts); + // const r2 = await fetch(`http://192.168.1.110:8000/analyze`, opts); + const jj = await r1.json(); + return jj; +} +export async function segmentateThai(sentence: string): Promise<AnalyzeRes[]> { + const opts = { + method: "POST", + headers: { "Content-type": "application/json" }, + body: JSON.stringify({ word: sentence }), + }; + // const r1 = await fetch(`http://localhost:8000/segmentate`, opts); + const r2 = await fetch("http://localhost:8001" + `/segmentate`, opts); + const jj = await r2.json(); + return jj; +} diff --git a/src/pages/lesson/[slug].tsx b/src/pages/lesson/[slug].tsx index 6632838..9e6e6cc 100644 --- a/src/pages/lesson/[slug].tsx +++ b/src/pages/lesson/[slug].tsx @@ -8,6 +8,8 @@ import type { PageProps } from "waku/router"; import db from "@/lib/db"; import { useCookies } from "@/lib/server/cookiebridge"; import Deck from "@/components/Flashcard/Deck"; +import Deck2 from "@/components/Flashcard/Deck2"; +import { CardFront, CardBack } from "@/components/Flashcard/ServerCard"; const flags: Record<string, string> = { th: "🇹🇭", @@ -34,12 +36,17 @@ export default async function HomePage(props: PageProps<"/lesson/[slug]">) { const data = await getData(Number(props.slug), user.id); if ("error" in data) return <p>Error</p>; // console.log({ data }); + const cardComponents = data.ok.cards.map((card) => ({ + id: card.id, + front: <CardFront data={card} />, + back: <CardBack data={card} />, + })); return ( <> <section> <h2 className="text-lg">Thai!</h2> - <Deck data={data.ok} /> + <Deck2 data={data.ok} cards={cardComponents} /> </section> </> ); |