From 9192e6c7747fd2d3f6a6c5c07d886a0982b53f11 Mon Sep 17 00:00:00 2001 From: polwex Date: Wed, 21 May 2025 15:28:03 +0700 Subject: good tandem good tandem of ideas and implementation --- src/actions/lang.ts | 7 + src/components/Flashcard/Card.tsx | 16 +- src/components/Flashcard/Deck2.tsx | 205 +++++++++++++++++++ src/components/Flashcard/ServerCard.tsx | 345 ++++++++++++++++++++++++++++++++ src/lib/db/schema.sql | 8 + src/lib/utils.ts | 8 + src/pages/api/nlp.ts | 38 ++++ src/pages/lesson/[slug].tsx | 9 +- 8 files changed, 634 insertions(+), 2 deletions(-) create mode 100644 src/components/Flashcard/Deck2.tsx create mode 100644 src/components/Flashcard/ServerCard.tsx 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 { // 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 = ({ } }; + const [isPending, startTransition] = useTransition(); + const handleClick = () => { + startTransition(async () => { + const res = await thaiAnalysis(data.expression.spelling); + }); + }; + return (
= ({ ))} -

+

{data.expression.spelling}

+ {isPending && }
{" "} {/* 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(0); + const [currentIndex, setCurrentIndex] = useState(0); + const [isFlipped, setIsFlipped] = useState(false); + const [animationDirection, setAnimationDirection] = useState< + "enter-left" | "enter-right" | "exit-left" | "exit-right" | "none" + >("none"); + const [isAnimating, setIsAnimating] = useState(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 ( +
+

No flashcards available.

+
+ ); + } + + const currentCard = cards[currentIndex]; + if (!currentCard) return

wtf

; + + return ( +
+
+ {/* This div is for positioning the card and managing overflow during animations */} +
+ +
+
+ +
+ +
+

+ Card {currentIndex + 1} of {cards.length} +

+ +
+ +
+ +
+ Use Arrow Keys (← →) to navigate, Space/Enter to flip. +
+
+ ); +} + +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 ( +
+
+ {front} + {back} +
+
+ ); +} 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 ( +
+ + {data.expression.spelling} +

+ } + > +

+ {extraData[0]?.syllables.map((syl) => ( + + {syl} + + ))} +

+
+ +
+ ); +} + +// Helper component for IPA display +export const IpaDisplay = ({ + ipaEntries, +}: { + ipaEntries: Array<{ ipa: string; tags?: string[] }>; +}) => { + if (!ipaEntries || ipaEntries.length === 0) return null; + return ( +
+ {ipaEntries.map((entry, index) => { + const tags = entry.tags ? entry.tags : []; + return ( + + {entry.ipa}{" "} + {tags.length > 0 && ( + ({tags.join(", ")}) + )} + + ); + })} + +
+ ); +}; + +export async function CardBack({ data }: { data: CardResponse }) { + // + return ( +
+ + + {data.expression.senses.map((ss, i) => ( +
+ {ss.senses.map((sss, i) => ( +
+ {sss.glosses.map((ssss, i) => ( +

{ssss}

+ ))} +
+ ))} +
+ ))} +
+

+ {data.note} +

+
+ ); +} + +// Component for displaying examples +const ExampleDisplay = ({ examples }: { examples: Example[] }) => { + if (!examples || examples.length === 0) return null; + return ( +
+
+ + Examples: +
+
    + {examples.map((ex, idx) => ( +
  • + "{ex.text}" + {ex.ref && ( + ({ex.ref}) + )} + {ex.type !== "quote" && ( + + {ex.type} + + )} +
  • + ))} +
+
+ ); +}; + +// 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 ( +
+ + {type}:{" "} + + {terms.map((term, idx) => ( + + + {term.word} + + {/*term.source && ( + ({term.source}) + )*/} + {idx < terms.length - 1 && ", "} + + ))} +
+ ); +}; + +// Component for displaying a SubSense +const SubSenseDisplay = ({ + subSense, + subSenseNumber, +}: { + subSense: SubSense; + subSenseNumber: number; +}) => { + return ( +
+ {subSense.glosses.map((gloss, glossIdx) => ( +

+ + {subSenseNumber}.{glossIdx + 1} + {" "} + {gloss} +

+ ))} + {subSense.raw_glosses && + subSense.raw_glosses.length > 0 && + subSense.raw_glosses.join("") !== subSense.glosses.join("") && ( +

+ (Raw: {subSense.raw_glosses.join("; ")}) +

+ )} + + {subSense.categories && subSense.categories.length > 0 && ( +
+
+ + Categories: +
+
+ {subSense.categories.map((cat, idx) => ( + + {cat} + + ))} +
+
+ )} + + + + + {subSense.tags && subSense.tags.length > 0 && ( +
+
+ + Tags: +
+
+ {subSense.tags.map((tag, idx) => ( + + {tag} + + ))} +
+
+ )} + + {subSense.links && subSense.links.length > 0 && ( +
+ {subSense.links.map(([type, target], linkIdx) => ( + + {type} + + ))} +
+ )} +
+ ); +}; + +// Component for individual sense +const SenseCard = ({ + senseData, + senseNumber, +}: { + senseData: Sense; + senseNumber: number; +}) => { + return ( +
+
+

+ {senseNumber}. {NLP.unpackPos(senseData.pos)} +

+
+ + {senseData.etymology && ( +
+ + Etymology + + + +

+ {senseData.etymology} +

+
+ )} + + {senseData.forms && senseData.forms.length > 0 && ( +
+

Forms:

+
+ {senseData.forms.map((form, idx) => ( + + {form.form}{" "} + {form.tags.length > 0 && `(${form.tags.join(", ")})`} + + ))} +
+
+ )} + + {senseData.senses.map((subSense, idx) => ( + + ))} + + {senseData.related && ( +
+

+ + Related Terms: +

+ + + + +
+ )} +
+ ); +}; 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( 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 => { return Response.json({ message: "Failure" }, { status: 500 }); } }; + +type AnalyzeRes = { + word: string; + syllables: string[]; + ipa: string; + pos: string; +}; + +export async function thaiData(word: string): Promise { + const [head, tail] = await Promise.all([ + analyzeTHWord(word), + segmentateThai(word), + ]); + return [head, ...tail]; +} + +export async function analyzeTHWord(word: string): Promise { + 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 { + 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 = { 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

Error

; // console.log({ data }); + const cardComponents = data.ok.cards.map((card) => ({ + id: card.id, + front: , + back: , + })); return ( <>

Thai!

- +
); -- cgit v1.2.3