diff options
author | polwex <polwex@sortug.com> | 2025-05-21 14:00:28 +0700 |
---|---|---|
committer | polwex <polwex@sortug.com> | 2025-05-21 14:00:28 +0700 |
commit | e839a5f61f0faa21ca8b4bd5767f7575d5e576ee (patch) | |
tree | 53e5bcc3977b6ebef687521a7ac387a89aeb21c8 | |
parent | 4f2bd597beaa778476b84c10b571db1b13524301 (diff) |
the card flip animation is legit
34 files changed, 2013 insertions, 188 deletions
@@ -4,10 +4,10 @@ "": { "name": "waku", "dependencies": { - "@edge-runtime/cookies": "^6.0.0", "@hookform/resolvers": "^5.0.1", "@radix-ui/react-dialog": "^1.1.13", "@radix-ui/react-label": "^2.1.6", + "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-select": "^2.2.4", "@radix-ui/react-slot": "^1.2.2", "class-variance-authority": "^0.7.1", @@ -85,8 +85,6 @@ "@babel/types": ["@babel/types@7.27.1", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q=="], - "@edge-runtime/cookies": ["@edge-runtime/cookies@6.0.0", "", {}, "sha512-VVO/8AwC2qVbygLb2IOkX1zWFx2yWIHzFv4D602CTnoRffd/+cdcXqpSydKaedFrk7a1dRYXbWwjzfV/gwZ2Gw=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="], @@ -225,6 +223,8 @@ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.2", "", { "dependencies": { "@radix-ui/react-slot": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-uHa+l/lKfxuDD2zjN/0peM/RhhSmRjr5YWdk/37EnSv1nJ88uvG85DPexSm8HdFQROd2VdERJ6ynXbkCFi+APw=="], + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="], + "@radix-ui/react-select": ["@radix-ui/react-select@2.2.4", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.6", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.9", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.6", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.6", "@radix-ui/react-portal": "1.1.8", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-slot": "1.2.2", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/OOm58Gil4Ev5zT8LyVzqfBcij4dTHYdeyuF5lMHZ2bIp0Lk9oETocYiJ5QC0dHekEQnK6L/FNJCceeb4AkZ6Q=="], "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ=="], @@ -855,6 +855,8 @@ "@anthropic-ai/sdk/@types/node": ["@types/node@18.19.100", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-ojmMP8SZBKprc3qGrGk8Ujpo80AXkrP7G2tOT4VWr5jlr5DHjsJF+emXJz+Wm0glmy4Js62oKMdZZ6B9Y+tEcA=="], + "@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="], @@ -877,6 +879,8 @@ "@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "@radix-ui/react-progress/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "groq-sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], diff --git a/message.txt b/message.txt new file mode 100644 index 0000000..402d946 --- /dev/null +++ b/message.txt @@ -0,0 +1,3 @@ +WHY ISNT THIS SHIT WORKING FOR FUCKS SAKE + +pepe from server!
\ No newline at end of file diff --git a/package.json b/package.json index 9fec0ca..8b39a03 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,10 @@ "start": "bunx --bun waku start" }, "dependencies": { - "@edge-runtime/cookies": "^6.0.0", "@hookform/resolvers": "^5.0.1", "@radix-ui/react-dialog": "^1.1.13", "@radix-ui/react-label": "^2.1.6", + "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-select": "^2.2.4", "@radix-ui/react-slot": "^1.2.2", "class-variance-authority": "^0.7.1", diff --git a/src/actions/login.ts b/src/actions/login.ts index 4ceb13f..ee15fe6 100644 --- a/src/actions/login.ts +++ b/src/actions/login.ts @@ -1,9 +1,7 @@ "use server"; -import { AsyncRes } from "@/lib/types"; +import type { AsyncRes } from "@/lib/types"; import db from "../lib/db"; -import cookie from "cookie"; -import { cookies } from "../lib/server/cookiebridge"; -import { unstable_redirect, unstable_rerenderRoute } from "waku/router/server"; +import { useCookies } from "../lib/server/cookiebridge"; export type FormState = { name?: string; @@ -11,21 +9,30 @@ export type FormState = { error?: string; success?: boolean; }; -async function call(formData: FormData, register: boolean) { +async function call( + formData: FormData, + register: boolean, +): AsyncRes<number | bigint> { const nam = formData.get("username"); const creds = formData.get("password"); const password = await Bun.password.hash(creds!.toString()); const name = nam!.toString(); - const res = register - ? db.addUser(name, password) - : await db.loginUser(name, creds!.toString()); - return res; + try { + const res = register + ? db.addUser(name, password) + : await db.loginUser(name, creds!.toString()); + return res; + } catch (e) { + console.error(e); + return { error: `${e}` }; + } } export async function postRegister( prevState: FormState, formData: FormData, ): Promise<FormState> { + // "use server"; const res = await call(formData, true); console.log("reg res", res); if ("error" in res) return { error: "Something went wrong" }; @@ -37,15 +44,15 @@ export async function postRegister( export async function postLogin( prevState: FormState, formData: FormData, -): Promise<FormState> { - return prevState; - // const res = await call(formData, false); - // if ("error" in res) return { error: res.error }; - // else { - // setCookie(res.ok as number); - // return prevState; - // // return { success: true }; - // } +): Promise<any> { + console.log(formData); + const res = await call(formData, false); + console.log({ res }); + if ("error" in res) return { error: res.error }; + else { + setCookie(res.ok as number); + return { success: true }; + } } async function setCookie(userId: number) { const COOKIE_EXPIRY = Date.now() + 1000 * 60 * 60 * 24 * 30; @@ -54,13 +61,13 @@ async function setCookie(userId: number) { const { randomBytes } = await import("node:crypto"); const cokistring = randomBytes(32).toBase64(); const res = db.setCookie(cokistring, userId, COOKIE_EXPIRY); - const { setCookie } = cookies(); - setCookie("sorlang", cokistring, COOKIE_OPTS); + const { setCookie } = useCookies(); + setCookie(cokistring); // unstable_redirect("/"); } -export async function postLogout(prev: number) { - const { delCookie } = cookies(); - const rest = delCookie("sorlang"); - return prev + 9; -} +// export async function postLogout(prev: number) { +// const { delCookie } = cookies(); +// const rest = delCookie("sorlang"); +// return prev + 9; +// } diff --git a/src/components/Flashcard/Card.tsx b/src/components/Flashcard/Card.tsx new file mode 100644 index 0000000..7cada24 --- /dev/null +++ b/src/components/Flashcard/Card.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { CardResponse } from "@/lib/types/cards"; + +// export default function ({ user }: { user: { name: string; id: number } }) { +// const [state, formAction, isPending] = useActionState(postLogout, 0); +// return ( +// <form action={formAction}> +// <Card> +// <CardHeader> +// <CardTitle>Profile</CardTitle> +// {state} +// </CardHeader> +// <CardContent> +// <p>Username: {user.name}</p> +// <p>User ID: {user.id}</p> +// </CardContent> +// <CardFooter> +// <Button type="submit">Log out</Button> +// </CardFooter> +// </Card> +// </form> +// ); +// } +// "use client"; + +// --- Flashcard Component --- +interface FlashcardProps { + data: CardResponse; + isFlipped: boolean; + onFlip: () => void; + animationDirection: + | "enter-left" + | "enter-right" + | "exit-left" + | "exit-right" + | "none"; +} + +const Flashcard: React.FC<FlashcardProps> = ({ + data, + isFlipped, + onFlip, + animationDirection, +}) => { + 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 of the card */} + <div className="absolute w-full h-full bg-white dark:bg-slate-800 rounded-xl backface-hidden flex flex-col justify-between items-center p-6"> + <span className="text-xs text-slate-500 dark:text-slate-400 self-start"> + {data.expression.ipa.map((ip, i) => ( + <span key={ip.ipa + i} className="ipa"> + {ip.ipa} + </span> + ))} + </span> + <p className="text-3xl md:text-4xl font-semibold text-slate-800 dark:text-slate-100 text-center"> + {data.expression.spelling} + </p> + <div className="w-full h-6"> + {" "} + {/* Placeholder for spacing, mimics bottom controls */} + <span className="text-xs text-slate-400 dark:text-slate-500 self-end invisible"> + Flip + </span> + </div> + </div> + + {/* Back of the card */} + <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"> + <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 className="w-full h-6"> + <span className="text-xs text-slate-400 dark:text-slate-500 self-end invisible"> + Flip + </span> + </div> + </div> + </div> + </div> + ); +}; + +export default Flashcard; diff --git a/src/components/Flashcard/Deck.tsx b/src/components/Flashcard/Deck.tsx new file mode 100644 index 0000000..d3c736f --- /dev/null +++ b/src/components/Flashcard/Deck.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { CardResponse, DeckResponse } from "@/lib/types/cards"; +import React, { useCallback, useEffect, useState } from "react"; +import { Button } from "../ui/button"; +import { ChevronLeftIcon, ChevronRightIcon, RotateCcwIcon } from "lucide-react"; +import "./cards.css"; +import Flashcard from "./Card"; + +// --- Main App Component --- +function Deck({ data }: { data: DeckResponse }) { + 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 flashcards = data.cards; + const [isAnimating, setIsAnimating] = useState<boolean>(false); + + const handleFlip = () => { + if (isAnimating) return; + setIsFlipped(!isFlipped); + }; + + const handleNext = useCallback(() => { + if (isAnimating || currentIndex >= flashcards.length - 1) return; + setIsAnimating(true); + setIsFlipped(false); // Flip back to front before changing card + setAnimationDirection("exit-left"); + + setTimeout(() => { + setCurrentIndex((prevIndex) => + Math.min(prevIndex + 1, flashcards.length - 1), + ); + setAnimationDirection("enter-right"); + setTimeout(() => { + setAnimationDirection("none"); + setIsAnimating(false); + }, 500); // Duration of enter animation + }, 500); // Duration of exit animation + }, [currentIndex, flashcards.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 (flashcards.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 = flashcards[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"> + {" "} + {/* Ensure this matches card height */} + <Flashcard + key={currentCard.id} // Important for re-rendering on card change with animation + data={currentCard} + isFlipped={isFlipped} + onFlip={handleFlip} + animationDirection={animationDirection} + /> + </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 {flashcards.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 === flashcards.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; diff --git a/src/components/Flashcard/cards.css b/src/components/Flashcard/cards.css new file mode 100644 index 0000000..2f75ad6 --- /dev/null +++ b/src/components/Flashcard/cards.css @@ -0,0 +1,86 @@ +body { + font-family: "Inter", sans-serif; +} + +.perspective { + perspective: 1000px; +} + +.transform-style-preserve-3d { + transform-style: preserve-3d; +} + +.backface-hidden { + backface-visibility: hidden; + -webkit-backface-visibility: hidden; + /* Safari */ +} + +.rotate-y-180 { + transform: rotateY(180deg); +} + +/* Slide animations */ +@keyframes slide-in-right { + from { + transform: translateX(100%); + opacity: 0; + } + + to { + transform: translateX(0); + opacity: 1; + } +} + +.animate-slide-in-right { + animation: slide-in-right 0.5s ease-out forwards; +} + +@keyframes slide-in-left { + from { + transform: translateX(-100%); + opacity: 0; + } + + to { + transform: translateX(0); + opacity: 1; + } +} + +.animate-slide-in-left { + animation: slide-in-left 0.5s ease-out forwards; +} + +@keyframes slide-out-left { + from { + transform: translateX(0); + opacity: 1; + } + + to { + transform: translateX(-100%); + opacity: 0; + } +} + +.animate-slide-out-left { + animation: slide-out-left 0.5s ease-in forwards; +} + +@keyframes slide-out-right { + from { + transform: translateX(0); + opacity: 1; + } + + to { + transform: translateX(100%); + opacity: 0; + } +} + +.animate-slide-out-right { + animation: slide-out-right 0.5s ease-in forwards; +}
\ No newline at end of file diff --git a/src/components/Login2.tsx b/src/components/Login2.tsx index ef3a603..6c26efc 100644 --- a/src/components/Login2.tsx +++ b/src/components/Login2.tsx @@ -11,19 +11,9 @@ import { CardFooter, CardTitle, } from "@/components/ui/card"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { useRouter } from "waku"; export default function AuthScreen() { const [isReg, setReg] = useState(false); @@ -52,15 +42,17 @@ function OOldform({ isReg, toggle }: { isReg: boolean; toggle: () => void }) { else setStrings(logstrings); }, [isReg]); + // const [state, formAction, isPending] = useActionState<FormState, FormData>( + // isReg ? postRegister : postLogin, + // { error: "" }, + // "/login", + // ); const [state, formAction, isPending] = useActionState<FormState, FormData>( - isReg ? postRegister : postLogin, + postLogin, { error: "" }, "/login", ); - // const nav = useRouter(); - // useEffect(() => { - // if (state.success) nav.replace("/"); - // }, [state]); + console.log({ state }); return ( <form action={formAction}> <div className="flex flex-col gap-6"> diff --git a/src/components/ParseForm.tsx b/src/components/ParseForm.tsx new file mode 100644 index 0000000..3e6f3e7 --- /dev/null +++ b/src/components/ParseForm.tsx @@ -0,0 +1,222 @@ +// src/components/SorlangPage.tsx +"use client"; // For Next.js App Router, if applicable + +import React, { + useState, + useRef, + useTransition, + useEffect, + useCallback, + startTransition, +} from "react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Loader2 } from "lucide-react"; // Loading spinner +import { useRouter } from "waku"; + +const SorlangPage: React.FC = () => { + const [textValue, setTextValue] = useState<string>(""); + const [pastedImageUrl, setPastedImageUrl] = useState<string | null>(null); + const [pastedImageFile, setPastedImageFile] = useState<File | null>(null); // Store the file for extraction + const [isAnalyzing, setIsAnalyzing] = useState<boolean>(false); + const [isExtracting, setIsExtracting] = useState<boolean>(false); + + const textareaRef = useRef<HTMLTextAreaElement>(null); + + // Cleanup object URL when component unmounts or image changes + useEffect(() => { + return () => { + if (pastedImageUrl) { + URL.revokeObjectURL(pastedImageUrl); + } + }; + }, [pastedImageUrl]); + + const handlePaste = useCallback( + (event: ClipboardEvent) => { + const items = event.clipboardData?.items; + console.log({ items }); + if (!items) return; + + let imageFound = false; + for (const item of items) { + if (item.kind === "file" && item.type.startsWith("image/")) { + event.preventDefault(); // Prevent pasting image data as text + const file = item.getAsFile(); + if (file) { + if (pastedImageUrl) { + URL.revokeObjectURL(pastedImageUrl); // Revoke previous if any + } + const newImageUrl = URL.createObjectURL(file); + setPastedImageUrl(newImageUrl); + setPastedImageFile(file); + imageFound = true; + } + break; // Handle first image found + } + } + + // // If no image was found, let the default text paste happen + // // Or, if you want to explicitly handle text paste: + // if (!imageFound) { + // // Let the default textarea paste handle it, or: + // // event.preventDefault(); + // // const text = event.clipboardData.getData('text/plain'); + // // setTextValue(prev => prev + text); // Or replace, depending on desired behavior + // // setPastedImageUrl(null); // Clear image if text is pasted + // // setPastedImageFile(null); + // } + }, + [pastedImageUrl], + ); + useEffect(() => { + window.addEventListener("paste", handlePaste); + return () => { + window.removeEventListener("paste", handlePaste); + }; + }, [handlePaste]); + + const router = useRouter(); + async function fetchNLP(text: string, app: "spacy" | "stanza") { + const opts = { + method: "POST", + headers: { "Content-type": "application/json" }, + body: JSON.stringify({ text, app }), + }; + const res = await fetch("/api/nlp", opts); + const j = await res.json(); + console.log("j", j); + if ("ok" in j) { + sessionStorage.setItem(`${app}res`, JSON.stringify(j.ok)); + } + } + + const handleProcessText = async () => { + setIsAnalyzing(true); + const text = textValue.trim(); + if (!text) { + alert("Text area is empty!"); + return; + } + await Promise.all([fetchNLP(text, "spacy"), fetchNLP(text, "stanza")]); + router.push("/zoom"); + setIsAnalyzing(false); + }; + + // const [isPending, startTransition] = useTransition(); + const handleExtractTextFromImage = async () => { + if (!pastedImageFile) { + alert("No image to extract text from!"); + return; + } + setIsExtracting(true); + const formData = new FormData(); + formData.append("file", pastedImageFile, pastedImageFile.name); + console.log("Extracting text from image:", pastedImageFile.name); + const res = await fetch("/api/formdata/ocr", { + method: "POST", + body: formData, + }); + const j = await res.json(); + console.log("ocr res", j); + if ("ok" in j) { + setTextValue((t) => t + j.ok.join("\n")); + } + setIsExtracting(false); + // handleClearImage(); + }; + + // setPastedImageUrl(null); + // setPastedImageFile(null); + + const handleClearImage = () => { + if (pastedImageUrl) { + URL.revokeObjectURL(pastedImageUrl); + } + setPastedImageUrl(null); + setPastedImageFile(null); + }; + + return ( + <div className="flex min-h-screen flex-col items-center justify-center bg-background p-4"> + <Card className="w-full max-w-2xl"> + <CardHeader> + <CardTitle className="text-center text-3xl font-bold"> + Sorlang + </CardTitle> + </CardHeader> + <CardContent className="space-y-6"> + <Textarea + ref={textareaRef} + value={textValue} + onChange={(e) => setTextValue(e.target.value)} + placeholder="Paste text here, or paste an image..." + className="min-h-[200px] w-full text-base" + aria-label="Input text area" + /> + + {pastedImageUrl && ( + <div className="mt-4 p-4 border rounded-md bg-muted/40"> + <div className="flex justify-between items-start mb-2"> + <h3 className="text-lg font-semibold">Pasted Image:</h3> + <Button + variant="ghost" + size="sm" + onClick={handleClearImage} + className="text-xs" + > + Clear Image + </Button> + </div> + <img + src={pastedImageUrl} + alt="Pasted content" + className="max-w-full max-h-60 mx-auto rounded-md border" + /> + </div> + )} + </CardContent> + <CardFooter className="flex flex-col sm:flex-row justify-center gap-4"> + {pastedImageUrl ? ( + <Button + onClick={handleExtractTextFromImage} + disabled={isExtracting} + className="w-full sm:w-auto" + > + {isExtracting ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + Extracting... + </> + ) : ( + "Extract Text from Image" + )} + </Button> + ) : ( + <Button onClick={handleProcessText} className="w-full sm:w-auto"> + {isAnalyzing ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> Analyzing... + </> + ) : ( + "Process Text" + )} + </Button> + )} + </CardFooter> + </Card> + <footer className="mt-8 text-center text-sm text-muted-foreground"> + <p>© {new Date().getFullYear()} Sorlang App. All rights reserved.</p> + </footer> + </div> + ); +}; + +export default SorlangPage; diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 0000000..10af7e6 --- /dev/null +++ b/src/components/ui/progress.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" + +import { cn } from "@/lib/utils" + +function Progress({ + className, + value, + ...props +}: React.ComponentProps<typeof ProgressPrimitive.Root>) { + return ( + <ProgressPrimitive.Root + data-slot="progress" + className={cn( + "bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", + className + )} + {...props} + > + <ProgressPrimitive.Indicator + data-slot="progress-indicator" + className="bg-primary h-full w-full flex-1 transition-all" + style={{ transform: `translateX(-${100 - (value || 0)}%)` }} + /> + </ProgressPrimitive.Root> + ) +} + +export { Progress } diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index a710a1e..b43edc3 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -1,8 +1,9 @@ import Database from "bun:sqlite"; import { getDBOffset, wordFactorial } from "../utils"; -import type { AddSense, AddWord, State } from "../types"; +import type { AddSense, AddWord, Result, State } from "../types"; import { DEFAULT_SRS } from "../services/srs"; import { DBWord, WordData } from "@/zoom/logic/types"; +import { DeckResponse } from "../types/cards"; const PAGE_SIZE = 100; @@ -57,16 +58,35 @@ class DatabaseHandler { // read // fetchLanguage(lang: string, page?: number) { - const query = this.db.query( - ` - SELECT * FROM lessons - JOIN languages l ON l.code = lessons.lang - WHERE lessons.lang= ? - `, - ); + const query = this.db.query(` + SELECT + l.id, + l.name, + l.position, + l.description, + COUNT(cl.card_id) AS count + FROM + lessons l + INNER JOIN + lang_lessons ll ON l.id = ll.lesson_id + LEFT JOIN + cards_lessons cl ON l.id = cl.lesson_id + WHERE + ll.lang = ? + GROUP BY + l.id, l.name, l.position, l.description + ORDER BY + l.position, l.name; + `); const res = query.all(lang); console.log(res, "results"); - return res; + return res as Array<{ + id: number; + count: number; + name: string; + description: string; + position: number; + }>; } fetchSyllables(lang: string, page?: number) { const query = this.db.query( @@ -135,7 +155,7 @@ class DatabaseHandler { GROUP_CONCAT(c.name, ',') AS category, FROM expressions JOIN word_categories wc ON wc.word_id = words.id - JOIN categories c ON c.id = wc.category_id + JOIN categories c ON c.name = wc.category WHERE spelling = $spelling GROUP BY words.id `); @@ -151,7 +171,7 @@ class DatabaseHandler { GROUP_CONCAT(c.name, ',') AS category FROM expressions e JOIN word_categories wc ON wc.word_id = e.id - JOIN categories c ON c.id = wc.category_id + JOIN categories c ON c.name= wc.category ORDER BY e.frequency DESC LIMIT $count OFFSET $offset @@ -297,7 +317,7 @@ class DatabaseHandler { LEFT JOIN word_categories wc ON wc.word_id = e.id LEFT JOIN - categories cat ON cat.id = wc.category_id + categories cat ON cat.name = wc.category GROUP BY l.id, c.id, e.id ORDER BY @@ -309,7 +329,12 @@ class DatabaseHandler { } // SELECT l.id, l.text, cards.text, cards.note FROM cards_lessons cl LEFT JOIN lessons l ON l.id = cl.lesson_id LEFT JOIN cards ON cards.id = cl.card_id ORDER BY l.id ASC LIMIT 20 OFFSET 0; - fetchLesson(userId: number, lessonId: number, count?: number, page?: number) { + fetchLesson( + userId: number, + lessonId: number, + count?: number, + page?: number, + ): Result<DeckResponse> { const p = page ? page : 1; const size = count ? count : PAGE_SIZE; const offset = getDBOffset(p, size); @@ -318,7 +343,7 @@ class DatabaseHandler { console.log(tomorrow.getTime()); const queryString = ` SELECT - l.name, l.description, l.lang as llang, cards.text, cards.note, cards.id as cid, + l.name, l.description, ll.lang as llang, cards.text, cards.note, cards.id as cid, up.id as upid, up.repetition_count, up.ease_factor, @@ -342,6 +367,7 @@ class DatabaseHandler { WHERE cl_inner.lesson_id = l.id) AS total_card_count FROM cards_lessons cl JOIN lessons l ON l.id = cl.lesson_id + JOIN lang_lessons ll ON l.id = ll.lesson_id JOIN cards ON cards.id = cl.card_id JOIN cards_expressions ce ON cards.id = ce.card_id JOIN expressions e ON e.id = ce.expression_id @@ -367,7 +393,7 @@ class DatabaseHandler { const query = this.db.query(queryString); const res = query.all(userId, lessonId, tomorrow.getTime(), size, offset); console.log(res.length); - if (res.length === 0) return null; + if (res.length === 0) return { error: "Lesson not found" }; const row: any = res[0]; // console.log({ row }); const lesson = { @@ -377,8 +403,10 @@ class DatabaseHandler { language: row.llang, cardCount: row.total_card_count, }; + // TODO IPA, prosody, senses... should we unify the format on the wikisource standard? const cards = res.map((row: any) => { // TODO parse here...? + // console.log({ row }); const sense_array = JSON.parse(row.senses_array); const senses = sense_array.map((s: any) => { const senses = JSON.parse(s.senses); @@ -417,7 +445,7 @@ class DatabaseHandler { }; return card; }); - return { lesson, cards }; + return { ok: { lesson, cards } }; } fetchCard(cid: number, userid: number) { const query = this.db.query(` @@ -448,7 +476,7 @@ class DatabaseHandler { VALUES (${columns.map((c) => "?").join(",")}) `; const query = this.db.query(queryString).run(...Object.values(params)); - return query; + return query.lastInsertRowid; } addSense(params: AddSense) { const columns = Object.keys(params); @@ -521,25 +549,28 @@ class DatabaseHandler { }) { const { text, mnote, eid, lesson_id } = params; const note = mnote ? mnote : null; - const query = this.db.query(` + const tx = this.db.transaction(() => { + const query = this.db.query(` INSERT INTO cards(text, note) VALUES(?, ?) `); - const res = query.run(text, note); - const cid = res.lastInsertRowid; - const query2 = this.db.query(` + const res = query.run(text, note); + const cid = res.lastInsertRowid; + const query2 = this.db.query(` INSERT OR IGNORE INTO cards_expressions(card_id, expression_id) VALUES(?, ?) `); - query2.run(cid, eid); - if (lesson_id) { - const query = this.db.query(` + query2.run(cid, eid); + if (lesson_id) { + const query = this.db.query(` INSERT INTO cards_lessons(card_id, lesson_id) VALUES(?, ?) `); - query.run(cid, lesson_id); - } + query.run(cid, lesson_id); + } + }); + return tx(); } addCardO(lesson_id: number | bigint | null, text: string, mnote?: string) { // wtf is this fucntion when did I write this @@ -634,12 +665,9 @@ class DatabaseHandler { } addWCat(wordId: number | bigint, category: string) { const queryString = ` - INSERT - INTO word_categories(word_id, category_id) - VALUES($wordId, ( - SELECT id FROM categories - WHERE name = $category - )) + INSERT OR IGNORE + INTO word_categories(word_id, category) + VALUES($wordId, $category) `; const query = this.db.query(queryString); const res = query.run({ wordId, category }); diff --git a/src/lib/db/schema.sql b/src/lib/db/schema.sql index 1b678c5..8d1b288 100644 --- a/src/lib/db/schema.sql +++ b/src/lib/db/schema.sql @@ -114,9 +114,14 @@ CREATE TABLE IF NOT EXISTS lessons( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0, - description TEXT, - lang TEXT, - FOREIGN KEY (lang) REFERENCES languages(code) + description TEXT +); +CREATE TABLE IF NOT EXISTS lang_lessons( + lesson_id INTEGER NOT NULL, + lang TEXT NOT NULL, + PRIMARY KEY (lang, lesson_id), + FOREIGN KEY (lang) REFERENCES languages(code), + FOREIGN KEY (lesson_id) REFERENCES lessons(id) ); CREATE TABLE IF NOT EXISTS cards( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/src/lib/db/seed.ts b/src/lib/db/seed.ts new file mode 100644 index 0000000..c4094de --- /dev/null +++ b/src/lib/db/seed.ts @@ -0,0 +1,494 @@ +import { readWiktionaryDump } from "../services/wiki"; +import { getStressedSyllable, getSyllableCount } from "../utils"; +import useful from "@/lib/useful_thai.json"; +import db from "."; + +const SYMBOL_REGEX = new RegExp(/[\W\d]/); + +async function handleFile( + filename: string, + func: (line: string, idx: number) => void, +) { + const file = Bun.file(filename); + const s = file.stream(); + const reader = s.getReader(); + const decoder = new TextDecoder(); + let leftover = ""; + let lineCount = 0; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + const chunk = decoder.decode(value, { stream: true }); + const lines = (leftover + chunk).split("\n"); + + // Process each line except the last (which might be incomplete) + for (const line of lines.slice(0, -1)) { + lineCount++; + func(line, lineCount); + } + + // Save the last incomplete line to process in the next iteration + leftover = lines[lines.length - 1]; + } + + // Handle any remaining content after reading all chunks + if (leftover) func(leftover, lineCount + 1); +} + +function goodPos(pos: string): boolean { + const list = [ + "CC", + "DT", + "EX", + "IN", + "LS", + "MD", + "PDT", + "POS", + "PRP", + "PRP$", + "RP", + "TO", + "WDT", + "WP", + "WP$", + ]; + return list.includes(pos); +} +// function englishKaggle() { +// handleFile("../datasets/words_pos.csv", (line, idx) => { +// const [_, spelling, pos] = line.split(","); +// if (!goodPos(pos)) return; +// const rowid = addWord(db, spelling, "", "en-us", "word", null); +// const category = poss[pos] || "unknown;"; +// addCat(db, rowid, category); +// }); +// } +// async function englishIPA() { +// handleFile("ipa/en-us/ipadict.txt", (line, idx) => { +// const [spelling, ipa] = line.split(/\s+/); +// if (!spelling || !ipa) return; +// const hasSymbols = spelling.match(SYMBOL_REGEX); +// if (hasSymbols) return; +// const split = spelling.split(" "); +// const type = split.length > 1 ? "expression" : "word"; +// const subtype = null; +// addWord(db, spelling, ipa, "en-us", type, subtype); +// }); +// } + +async function englishFreq() { + handleFile( + "/home/y/code/prosody/hanchu/datasets/unigram_freq.csv", + (line, idx) => { + const [spelling, _frequency] = line.split(","); + db.addFrequency(spelling, idx); + }, + ); +} +async function thaiFreq() { + const files = [ + "/home/y/code/prosody/prosody/langdata/thai/data/1yin_freq.csv", + "/home/y/code/prosody/prosody/langdata/thai/data/2yin_freq.csv", + "/home/y/code/prosody/prosody/langdata/thai/data/3yin_freq.csv", + "/home/y/code/prosody/prosody/langdata/thai/data/4yin_freq.csv", + "/home/y/code/prosody/prosody/langdata/thai/data/5yin_freq.csv", + "/home/y/code/prosody/prosody/langdata/thai/data/6yin_freq.csv", + ]; + for (let f of files) { + handleFile(f, (line, idx) => { + const [spelling, IPA, tone, length, frequency, ...rest] = line.split(","); + db.addFrequency(spelling, Number(frequency)); + }); + } +} + +const thaiTones: Record<string, number> = { + M: 1, + L: 2, + F: 3, + H: 4, + R: 5, +}; +const thaiTones2: Record<string, number> = { + "˧": 1, + "˨˩": 2, + "˥˩": 3, + "˦˥": 4, + "˩˩˦": 5, +}; +async function thaiSyllables() { + handleFile( + "/home/y/code/prosody/prosody/langdata/thai/data/1yin_freq.csv", + (line, idx) => { + const [spelling, IPA, toneS, length, frequency, ...rest] = + line.split(","); + const isLong = length === "長"; + const tone = thaiTones[toneS]; + const prosody = JSON.stringify({ isLong, tone, lang: "th" }); + db.upsertWord({ + spelling, + lang: "th", + ipa: JSON.stringify([{ ipa: IPA, tags: ["sortug"] }]), + prosody, + syllables: 1, + type: "syllable", + frequency: Number(frequency), + confidence: 10, + }); + }, + ); + handleFile( + "/home/y/code/prosody/prosody/langdata/thai/data/1yinjie.csv", + (line, idx) => { + const [spelling, IPA] = line.split(","); + const isLong = IPA.includes("ː"); + let tone = 0; + const toneMarks = Object.keys(thaiTones2); + for (let tm of toneMarks) { + if (IPA.includes(tm)) tone = thaiTones2[tm]; + } + const prosody = JSON.stringify({ isLong, tone, lang: "th" }); + db.upsertWord({ + spelling, + lang: "th", + ipa: JSON.stringify([{ ipa: IPA, tags: ["sortug"] }]), + prosody, + syllables: 1, + type: "syllable", + confidence: 10, + }); + }, + ); +} + +// // Save the last incomplete line to process in the next iteration +// } +// TODO no conjunctions or adpositions in Wordnet!! +// function englishWordnet() { +// // LEFT JOIN lexes_pronunciations ukpr ON ukpr.wordid = words.wordid AND uspr.variety = 'GB' +// // LEFT JOIN pronunciations ukp ON ukp.pronunciationid = ukpr.pronunciationid +// const queryString = ` +// WITH ranked_ipa AS ( +// SELECT +// lp.wordid, +// pr.pronunciation, +// lp.variety, +// ROW_NUMBER() OVER ( +// PARTITION BY lp.wordid +// ORDER BY +// CASE +// WHEN lp.variety = 'US' THEN 1 +// WHEN lp.variety IS NULL THEN 2 +// WHEN lp.variety IS 'GB' THEN 3 +// ELSE 4 +// END +// ) AS rank +// FROM lexes_pronunciations lp +// JOIN pronunciations pr ON pr.pronunciationid = lp.pronunciationid +// ) +// SELECT words.wordid, word, rp.pronunciation as ipa, domainname +// FROM words +// LEFT JOIN ranked_ipa rp ON rp.wordid = words.wordid AND rp.rank = 1 +// LEFT JOIN senses ON senses.wordid = words.wordid +// LEFT JOIN synsets ON synsets.synsetid = senses.synsetid +// LEFT JOIN domains ON domains.domainid = synsets.domainid +// GROUP BY words.wordid +// `; +// const query = wndb.query(queryString); +// const res: Array<{ +// word: string; +// ipa: string; +// domainname: string; +// }> = query.all() as any; +// console.log("res", res.length); +// for (const r of res) { +// console.log(r, "r"); +// // if (r.word === 'abrasive') throw new Error('stop right here'); +// const ok = filterWord(r.word); +// if (!ok) continue; +// const split = r.word.split(" "); +// const type = split.length > 1 ? "expression" : "word"; +// const subtype = null; +// const wordid = addWord(db, r.word, r.ipa, "en-us", type, subtype); +// const category = domains[r.domainname] || "unknown;"; +// addCat(db, wordid, category); +// } +// } +function filterWord(s: string) { + const hasSymbols = s.match(SYMBOL_REGEX); + if (hasSymbols) return false; + else return true; +} + +// function checkWordNet(word: string) { +// const query = wndb.query(`SELECT * FROM words WHERE word = $word`); +// const res = query.get({ $word: word }); +// return !!res; +// } + +// function englishCards() { +// const lesson_id = addLesson(db, "First Lesson, some easy stuff"); +// const texts = [ +// "I", +// "friend", +// "my friend", +// "you", +// "your friend", +// "my friends' friend", +// "you are my friend", +// "I am your friend", +// "your friend is my friend", +// "my friend is your friend", +// "he is my friend", +// "this is mine", +// "this is yours", +// "this is my friends'", +// "no", +// "you are not my friend", +// "this is not yours", +// "your friend is not my friend", +// "that is mine", +// "this is mine, that is yours", +// "he is not your friend", +// "no, I am not", +// "that is not me", +// "that is not mine, that is my friends'", +// ]; +// for (const text of texts) { +// addCard(db, lesson_id, text); +// } +// } +// englishWordnet(); +// englishFreq(); +// englishCards(); +// englishKaggle(); + +async function fillFromDump() { + await db.init(); + // const log = Bun.file("./stuff.log"); + // const logWriter = log.writer(); + let count = 0; + const fields = new Set<string>(); + // let biggest = 0; + for await (const line of readWiktionaryDump()) { + try { + count++; + // if (count > 80) break; + // if (line.length > biggest) { + // biggest = line.length; + // Bun.write("./biggest.log", line, { createPath: true }); + // } + const j = JSON.parse(line); + db.addLanguage(j.lang_code, j.lang); + db.addCat(j.pos); + // for (let key of Object.keys(j)) { + // if (!fields.has(key)) { + // fields.add(key); + // logWriter.write(`${line}\n`); + // } + // } + if (j.lang_code === "en" || j.lang_code === "th") { + console.log("saving", j.word); + // console.log(j.sounds); + const related = { + derived: j.derived, + antonyms: j.antonyms, + synonyms: j.synonyms, + related: j.related, + }; + let rhyme = ""; + let ipaExample = ""; + let ipa: any[] = []; + for (let snd of j.sounds || []) { + if ("ipa" in snd) { + ipa.push(snd); + if (!ipaExample) ipaExample = snd.ipa; + } + if ("rhymes" in snd) rhyme = snd.rhymes; + } + const isWord = j.word.trim().split(" ").length === 1; + const type: any = isWord ? "word" : "expression"; + const syllables = ipaExample ? getSyllableCount(ipaExample) : 0; + console.log({ ipaExample, syllables }); + let prosody: any = {}; + if (ipaExample) { + const stressedSyllable = getStressedSyllable(ipaExample); + if ("ok" in stressedSyllable) + prosody.stressedSyllable = stressedSyllable.ok; + } + if (rhyme) prosody.rhyme = rhyme; + try { + const row = db.addWord({ + spelling: j.word, + lang: j.lang_code, + ipa: JSON.stringify(ipa), + prosody: JSON.stringify(prosody), + syllables, + type, + }); + let parent_id: number | bigint; + if (row.changes === 1) parent_id = row.lastInsertRowid; + else { + const data: any = db.fetchExpressionBySpelling(j.word, j.lang_code); + parent_id = data.id; + } + const senseRow = db.addSense({ + id: count - 1, + parent_id, + spelling: j.word, + etymology: j.etymology_text || "", + pos: j.pos, + ipa: JSON.stringify(ipa), + prosody: JSON.stringify(prosody), + senses: JSON.stringify(j.senses), + forms: JSON.stringify(j.forms || []), + related: JSON.stringify(related), + }); + } catch (e) { + console.log("error inserting", e); + } + } + // langset.add(j.lang_code); + // if (j.lang === "Translingual") continue; + // if (j.lang_code === "en") en++; + // if (j.lang_code === "th") thai++; + // if (j.lang_code === "zh") zh++; + + // if (j.word === "cat") { + // console.log(j.word); + // console.log(Object.keys(j)); + // console.log(j); + // console.log("senses", j.senses); + // console.log("forms", j.forms); + // // console.log("ett", j.etymology_templates); + // // console.log("derived", j.derived); + // // const meaning: Meaning = {etymology: j.etymology_text} + // // const wd = { lang: j.lang_code, spelling: j.word, ipa, {} }; + // break; + // } + } catch (e) { + console.log("error parsing", e); + } + } + console.log("fields", fields); +} + +function addDecks() { + // const lesson_id = db.addLesson({ + // name: "Thai Syllables", + // description: "All the syllables in the Thai language ordered by frequency", + // lang: "th", + // }); + const syllables: any[] = db.fetchExpressionRaw({ + confidence: "10", + syllables: "1", + lang: "th", + }); + for (let expression of syllables) { + db.addCard({ + lesson_id: 5, + eid: expression.id, + text: "Syllable", + mnote: "from Sortug Development", + }); + } +} +function adjustFrequency(lang: string) { + const frequencies: Set<number> = new Set(); + const all: any[] = db.fetchExpressionRaw({ lang }); + for (let row of all) { + if (row.frequency) frequencies.add(row.frequency); + } + const freqArray = Array.from(frequencies).sort((a, b) => b - a); + console.log(freqArray); + for (let row of all) { + if (row.frequency) { + const f = freqArray.indexOf(row.frequency); + if (f === -1) throw new Error("wtf" + row.frequency); + db.updateWord(row.id, { frequency: f + 1 }); + } + } +} + +// -- INSERT INTO lessons(name, description) values('8000 Super Useful Expressions', 'David Martins Facebook list of coloquial Thai expressions'); +// -- INSERT INTO lang_lessons(lesson_id, lang) VALUES(1, 'th'); +// -- INSERT INTO lessons(name, description) values('Thai Syllables', 'All syllables in Thai phonology'); +// -- INSERT INTO lang_lessons(lesson_id, lang) VALUES(2, 'th'); +function addThaiUseful() { + let idx = 0; + for (const level in useful) { + db.addCat(level); + const exps = (useful as any)[level]; + console.log(level, exps.length); + for (const exp of exps) { + const split = exp.ipa.split("/").filter((s) => s.trim()); + const ipa = split.map((ip: any) => ({ ipa: ip, tags: [] })); + try { + idx++; + const tx = db.db.transaction(() => { + const wid = db.addWord({ + spelling: exp.spelling, + lang: "th", + type: "expression", + ipa: JSON.stringify(ipa), + }); + console.log({ wid }); + db.addWCat(wid, level); + if (exp.register) { + db.addCat(exp.register); + db.addWCat(wid, exp.register); + } + const glosses = [exp.english]; + if (exp.note) glosses.push(exp.note); + db.addSense({ + parent_id: wid, + spelling: exp.spelling, + senses: JSON.stringify([{ glosses }]), + }); + db.addCard({ + text: `Super Useful ${idx}`, + eid: wid as any, + lesson_id: 1, + }); + }); + tx(); + } catch (e) { + console.log({ exp }); + console.error(`${e}`); + // break; + } + } + } +} + +function addThaiSyllablesLesson() { + const res = db.db + .query( + "SELECT id FROM expressions e WHERE e.type = 'syllable' and e.lang = 'th'", + ) + .all() as any[]; + for (const row of res) { + db.addCard({ text: "Syllable", eid: row.id, lesson_id: 2 }); + } +} +// function fixIpa() { +// const res = db.db.query(`SELECT id, ipa FROM expressions`).all() as any[]; +// for (const row of res) { +// try { +// const jon = JSON.parse(row.ipa); +// } catch (_) { +// const clean: string = row.ipa.replace("...", "").trim(); +// db.db.query(`UPDATE expressions SET ipa = ? WHERE `).run(JSON.stringify(ipa)); +// } +// } +// } +addThaiUseful(); +// addThaiSyllablesLesson(); + +// adjustFrequency("th"); + +// addDecks(); +// fillFromDump(); +// thaiSyllables(); +// thaiFreq(); diff --git a/src/lib/hooks/useCookie.ts b/src/lib/hooks/useCookie.ts new file mode 100644 index 0000000..904738a --- /dev/null +++ b/src/lib/hooks/useCookie.ts @@ -0,0 +1,35 @@ +import { getContext } from "waku/middleware/context"; +import { mergeSetCookies } from "./setcookie"; + +const useCookies = () => { + const ctx = getContext(); + console.log(ctx.req, "cookie bridge"); + const headers = ctx.req.headers; + console.log({ headers }); + return "hi"; + + // const headerObj = ctx.headers || {}; + // headerObj["set-cookie"] = mergeSetCookies( + // headerObj["set-cookie"] || [], + // (ctx.cookies || []) as ResponseCookie[], + // ); + // const headers = new Headers(headerObj as Record<string, string>); + // const reqCookies = new RequestCookies(headers); + // const resCookies = new ResponseCookies(headers); + + // const getCookie: ResponseCookies["get"] = (...args) => + // resCookies.get(...args) || reqCookies.get(...args); + // const setCookie: ResponseCookies["set"] = (...args) => { + // const updated = resCookies.set(...args); + // ctx.cookies = updated.getAll(); + // return updated; + // }; + // const delCookie: ResponseCookies["delete"] = (...args) => { + // const updated = resCookies.delete(...args); + // ctx.cookies = updated.getAll(); + // return updated; + // }; + // return { getCookie, setCookie, delCookie }; +}; + +export { useCookies }; diff --git a/src/lib/resources/lang.ts b/src/lib/resources/lang.ts new file mode 100644 index 0000000..caa4c03 --- /dev/null +++ b/src/lib/resources/lang.ts @@ -0,0 +1,8 @@ +export const flags: Record<string, string> = { + th: "🇹🇭", + en: "🇬🇧", + zh: "🇨🇳", + ja: "🇯🇵", + es: "🇪🇸", + fr: "🇫🇷", +}; diff --git a/src/lib/server/cookie.ts b/src/lib/server/cookie.ts index 30f215e..bbabd63 100644 --- a/src/lib/server/cookie.ts +++ b/src/lib/server/cookie.ts @@ -1,47 +1,41 @@ -import { - getCookie, - getSignedCookie, - setCookie, - setSignedCookie, - deleteCookie, -} from "hono/cookie"; +import { getHonoContext } from "waku/unstable_hono"; import cookie from "cookie"; -// console.log("db module path:", "@/lib/db"); -// console.log( -// "globalThis.__WAKU_MIDDLEWARE_CONTEXT_STORAGE__:", -// globalThis.__WAKU_MIDDLEWARE_CONTEXT_STORAGE__, -// ); import db from "../db"; import type { Middleware } from "waku/config"; -// XXX we would probably like to extend config. - const cookieMiddleware: Middleware = () => { console.log("cookieMiddleware executed"); return async (ctx, next) => { const cookies = cookie.parse(ctx.req.headers.cookie || ""); - console.log({ cookies }); const coki = cookies.sorlang; - if (!coki) { - if (ctx.req.url.pathname === "/login") return await next(); - ctx.res.status = 301; - ctx.res.headers = { - Location: "/login", - }; - } + // if (!coki) { + // if (ctx.req.url.pathname === "/login") return await next(); + // ctx.res.status = 301; + // ctx.res.headers = { + // Location: "/login", + // }; + // } if (coki) { const userRow = db.fetchCookie(coki); + console.log({ userRow }); if (userRow) ctx.data.user = { id: userRow.id, name: userRow.name }; - else { - if (ctx.req.url.pathname === "/login") return await next(); - ctx.res.status = 301; - ctx.res.headers = { - Location: "/login", - }; - } + // else { + // if (ctx.req.url.pathname === "/login") return await next(); + // ctx.res.status = 301; + // ctx.res.headers = { + // Location: "/login", + // }; + // } } await next(); + const hctx: any = getHonoContext(); + console.log("hono", hctx.lol); + console.log("ctx coki", ctx.data.cookie); + ctx.res.headers ||= {}; + if (ctx.data.cookie) + ctx.res.headers["set-cookie"] = ctx.data.cookie as string; + ctx.res.headers["set-lmao"] = "wtf man"; }; }; diff --git a/src/lib/server/cookiebridge.ts b/src/lib/server/cookiebridge.ts index 4dce095..778fc2c 100644 --- a/src/lib/server/cookiebridge.ts +++ b/src/lib/server/cookiebridge.ts @@ -1,38 +1,52 @@ -import { getContextData } from "waku/middleware/context"; -import { - RequestCookies, - ResponseCookies, - type ResponseCookie, -} from "@edge-runtime/cookies"; -import { mergeSetCookies } from "./setcookie"; +import { getContext, getContextData } from "waku/middleware/context"; -const cookies = () => { - const ctx = getContextData() as { - headers: Record<string, string | string[]>; - cookies?: ResponseCookie[]; - }; - const headerObj = ctx.headers || {}; - headerObj["set-cookie"] = mergeSetCookies( - headerObj["set-cookie"] || [], - (ctx.cookies || []) as ResponseCookie[], - ); - const headers = new Headers(headerObj as Record<string, string>); - const reqCookies = new RequestCookies(headers); - const resCookies = new ResponseCookies(headers); +const useCookies = () => { + const ctx = getContext(); + const headers = ctx.req.headers; + console.log(headers.cookie); - const getCookie: ResponseCookies["get"] = (...args) => - resCookies.get(...args) || reqCookies.get(...args); - const setCookie: ResponseCookies["set"] = (...args) => { - const updated = resCookies.set(...args); - ctx.cookies = updated.getAll(); - return updated; + const getCookie = (s: string) => { + const coki = headers.cookie; + if (!coki) return {}; + const cokiMap = parseCoki(coki); + return cokiMap; + }; + const setCookie = (s: string) => { + const ctxdata = getContextData(); + // (ctxdata.cokimap as Record<string, string>).sorlang = s; + // ctxdata.cookie = `sorlang=${s}; Secure` + ctxdata.cookie = `sorlang=${s};`; }; - const delCookie: ResponseCookies["delete"] = (...args) => { - const updated = resCookies.delete(...args); - ctx.cookies = updated.getAll(); - return updated; + const delCookie = (s: string) => { + const ctxdata = getContextData(); + delete (ctxdata.cokimap as Record<string, string>).sorlang; }; return { getCookie, setCookie, delCookie }; }; -export { cookies }; +export { useCookies }; + +function parseCoki(s: string) { + return s + .split(";") + .map((v) => v.split("=")) + .reduce((acc: Record<string, string>, v: any) => { + acc[decodeURIComponent(v[0].trim())] = decodeURIComponent(v[1].trim()); + return acc; + }, {}); +} +function parseSetCoki(s: string) { + return s + .split(";") + .map((v) => v.split("=")) + .reduce((acc: Record<string, string>, v: any) => { + acc[decodeURIComponent(v[0].trim())] = decodeURIComponent(v[1].trim()); + return acc; + }, {}); +} +function cokiToString(m: Record<string, string>): string { + return Object.entries(m).reduce((acc: string, item: [string, string]) => { + const [key, val] = item; + return `${acc} ${key}=${val};`; + }, ""); +} diff --git a/src/lib/server/setcookie.ts b/src/lib/server/setcookie.ts index f64b380..61da128 100644 --- a/src/lib/server/setcookie.ts +++ b/src/lib/server/setcookie.ts @@ -1,24 +1,10 @@ import type { Middleware } from "waku/config"; -import { type ResponseCookie, stringifyCookie } from "@edge-runtime/cookies"; - -export const mergeSetCookies = ( - resSetCookies: string | string[], - cookiesInContext: ResponseCookie[], -) => { - if (typeof resSetCookies === "string") { - resSetCookies = [resSetCookies]; - } - return [...resSetCookies, ...cookiesInContext.map(stringifyCookie)]; -}; const setCookieMiddleware: Middleware = () => { return async (ctx, next) => { await next(); ctx.res.headers ||= {}; - ctx.res.headers["set-cookie"] = mergeSetCookies( - ctx.res.headers["set-cookie"] || [], - (ctx.data.cookies || []) as ResponseCookie[], - ); + ctx.res.headers["set-cookie"] = ctx.data.cookie as string; }; }; diff --git a/src/lib/types/cards.ts b/src/lib/types/cards.ts new file mode 100644 index 0000000..0592a34 --- /dev/null +++ b/src/lib/types/cards.ts @@ -0,0 +1,210 @@ +// src/types.ts + +import { ReactNode } from "react"; + +// Language definition +export interface Language { + code: string; + name: string; + nativeName?: string; + supportsSource?: boolean; + supportsTarget?: boolean; + progress: number; + cardCount: number; +} + +// Translation service provider +export interface Provider { + id: string; + name: string; +} + +// Translation history item +export interface TranslationHistoryItem { + id: number; + text: string; + translation: string; + from: string; + to: string; + provider: string; + timestamp: number; +} + +// Response from translation API +export interface TranslationResponse { + translation: string; + source: string; + target: string; + provider: string; + characters: number; +} + +// Error response from API +export interface ErrorResponse { + error: string; + details?: any; +} + +// Props for components +export interface LanguageSelectorProps { + value: string; + onChange: (value: string) => void; + languages: Language[]; + showCharCount?: boolean; + charCount?: number; + showCopyButton?: boolean; + onCopy?: () => void; + setMore?: (t: { text: string; lang: string }) => void; + text?: string; + disabled?: boolean; +} + +export interface TextAreaProps { + value: string; + onChange: (value: string) => void; + lang?: string; + transliteration?: TransliterationOptions; + placeholder: string; + readOnly?: boolean; +} + +export interface ProviderSelectorProps { + value: string; + onChange: (value: string) => void; + providers: Provider[]; +} + +export interface TranslationHistoryProps { + items: TranslationHistoryItem[]; + languages: Language[]; + providers: Provider[]; +} + +export interface TransliterationOptions extends Language { + scripts: TransliterationLanguage[]; +} +export interface TransliterationLanguage extends Language { + toScripts: Language[]; +} + +export type Meaning = { + pos: string; // part of speech; + meaning: string[]; + etymology: string; + references?: any; +}; + +export type Prompts = { + translate: string; +}; +export type AnalyzeRes = { + word: string; + syllables: string[]; + ipa: string; + pos: POS; +}; +type POS = string; + +export type WordData = { + spelling: string; + lang: string; + ipa: string; + meanings: Meaning[]; + references?: any; +}; + +// app proper +// Mock data for the app +export type UserStats = { + streakDays: number; + cardsLearned: number; + minutesStudied: number; + dueToday: number; +}; +export type UserData = { + id: number; + name: string; + stats: UserStats; +}; +export type DeckResponse = { + lesson: { + name: string; + description: string; + language: string; + id: number; + cardCount: number; + }; + cards: CardResponse[]; +}; + +export interface SRSProgress { + repetitionCount: number; + easeFactor: number; + interval: number; + nextReviewDate: number; + lastReviewed: number; + isMastered: boolean; +} + +export interface ReviewResult { + cardId: number; + accuracy: number; + reviewTime: number; +} +export type CardResponse = { + id: number; + text: string; + note: string; + progress: SRSProgress; + expression: { + ipa: Array<{ ipa: string; tags: string[] }>; + spelling: string; + type: ExpressionType; + syllables: number; + confidence: number; + lang: string; + frequency: number; + prosody: any; + senses: Sense[]; + }; +}; +export type Sense = { + etymology: string; + pos: string; + forms: Array<{ form: string; tags: string[] }>; + related: any; + senses: Array<{ glosses: string[]; links: Array<[string, string]> }>; +}; + +export type SyllableProsody = { isLong: boolean; tone: number; lang: string }; + +export interface Deck { + id: number; + name: string; + description: string; + cardCount: number; + dueCards: number; + progress: number; + language: string; +} + +export interface Card { + id: number; + front: ReactNode; + back: ReactNode; + language: string; + difficulty: number; + nextReview: Date; + interval: number; + easeFactor: number; +} + +export type ExpressionType = "word" | "expression" | "syllable"; +export type ExpressionSearchParams = { + lang?: string; + spelling?: string; + pos?: string; + syllables?: { num: number; sign: string }; // ">" | "<" | "=" + frequency?: { num: number; above: boolean }; + type?: ExpressionType; +}; diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 0a46643..ce3f5fb 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -98,7 +98,7 @@ export type AddSense = { parent_id: number | bigint; spelling: string; etymology?: string; - pos: string; + pos?: string; ipa?: string; prosody?: string; senses?: string; diff --git a/src/lib/useful_thai.json b/src/lib/useful_thai.json new file mode 120000 index 0000000..a24a80f --- /dev/null +++ b/src/lib/useful_thai.json @@ -0,0 +1 @@ +/home/y/code/bun/scripts/useful_thai.json
\ No newline at end of file diff --git a/src/pages.gen.ts b/src/pages.gen.ts index 2cb3b4a..7d23584 100644 --- a/src/pages.gen.ts +++ b/src/pages.gen.ts @@ -6,8 +6,14 @@ import type { PathsForPages, GetConfigResponse } from 'waku/router'; // prettier-ignore import type { getConfig as Zoom_getConfig } from './pages/zoom'; // prettier-ignore +import type { getConfig as LangSlug_getConfig } from './pages/lang/[slug]'; +// prettier-ignore +import type { getConfig as LessonSlug_getConfig } from './pages/lesson/[slug]'; +// prettier-ignore import type { getConfig as Login_getConfig } from './pages/login'; // prettier-ignore +import type { getConfig as Parse_getConfig } from './pages/parse'; +// prettier-ignore import type { getConfig as Db_getConfig } from './pages/db'; // prettier-ignore import type { getConfig as Form_getConfig } from './pages/form'; @@ -16,12 +22,17 @@ import type { getConfig as Picker_getConfig } from './pages/picker'; // prettier-ignore import type { getConfig as About_getConfig } from './pages/about'; // prettier-ignore +import type { getConfig as LogintestIndex_getConfig } from './pages/logintest/index'; +// prettier-ignore import type { getConfig as Index_getConfig } from './pages/index'; // prettier-ignore type Page = | ({ path: '/zoom' } & GetConfigResponse<typeof Zoom_getConfig>) +| ({ path: '/lang/[slug]' } & GetConfigResponse<typeof LangSlug_getConfig>) +| ({ path: '/lesson/[slug]' } & GetConfigResponse<typeof LessonSlug_getConfig>) | ({ path: '/login' } & GetConfigResponse<typeof Login_getConfig>) +| ({ path: '/parse' } & GetConfigResponse<typeof Parse_getConfig>) | ({ path: '/db' } & GetConfigResponse<typeof Db_getConfig>) | { path: '/test/client-modal'; render: 'dynamic' } | { path: '/test/trigger-modal-button'; render: 'dynamic' } @@ -29,6 +40,9 @@ type Page = | ({ path: '/form' } & GetConfigResponse<typeof Form_getConfig>) | ({ path: '/picker' } & GetConfigResponse<typeof Picker_getConfig>) | ({ path: '/about' } & GetConfigResponse<typeof About_getConfig>) +| { path: '/logintest/Form'; render: 'dynamic' } +| ({ path: '/logintest' } & GetConfigResponse<typeof LogintestIndex_getConfig>) +| { path: '/logintest/ServerForm'; render: 'dynamic' } | ({ path: '/' } & GetConfigResponse<typeof Index_getConfig>); // prettier-ignore diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 82ffd99..48fa46e 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,13 +1,62 @@ import { Link } from "waku"; -import { Counter } from "../components/counter"; +import { Progress } from "@/components/ui/progress"; import { getContextData } from "waku/middleware/context"; -import Main from "../components/Main"; + +type LanguageChoice = "th" | "en" | "zh" | "ja" | "es" | "fr"; +type LangMeta = { flag: string; name: string }; +const langs: Record<LanguageChoice, LangMeta> = { + th: { flag: "🇹🇭", name: "Thai" }, + en: { flag: "🇬🇧", name: "English" }, + zh: { flag: "🇨🇳", name: "Chinese" }, + ja: { flag: "🇯🇵", name: "Japanese" }, + es: { flag: "🇪🇸", name: "Spanish" }, + fr: { flag: "🇫🇷", name: "French" }, +}; export default async function HomePage() { const { user } = getContextData(); - return <Main />; + return ( + <div className="min-h-screen bg-gray-50"> + <header className="bg-white shadow-sm sticky top-0 z-50"> + <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> + <div className="flex justify-between items-center h-16"> + <div className="flex items-center"> + <h1 className="text-2xl font-bold text-indigo-600">Prosody</h1> + </div> + + {/* Desktop Navigation */} + <nav className="hidden md:flex space-x-8"> + <Link to="/"> + <button + className={`py-2 font-medium text-indigo-600 border-b-2 border-indigo-600`} + > + Home + </button> + </Link> + <Link to="/parse"> + <button + className={`py-2 font-medium text-gray-600 hover:text-indigo-600`} + > + Analyze Text + </button> + </Link> + </nav> + </div> + </div> + + {/* Mobile Navigation */} + </header> + <section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-8"> + <h2 className="text-lg"> Your Languages</h2> + <LanguageItem lang="en" /> + <LanguageItem lang="th" /> + <LanguageItem lang="zh" /> + <LanguageItem lang="ja" /> + </section> + </div> + ); } const getData = async () => { @@ -25,3 +74,54 @@ export const getConfig = async () => { render: "dynamic", } as const; }; + +async function LanguageItem({ lang }: { lang: LanguageChoice }) { + return ( + <Link to={`/lang/${lang}`}> + <div className="bg-white rounded-xl h-32 shadow-sm overflow-hidden hover:shadow-md transition-shadow duration-300"> + <div className="p-6"> + <div className="flex"> + <div className="text-lg">{langs[lang].flag}</div> + <div className="text-lg">{langs[lang].name}</div> + </div> + <Progress value={50} className="w-[60%]" /> + </div> + </div> + </Link> + ); +} +{ + /* Language Cards */ +} +// <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-8"> +// {languages.slice(0, 3).map((lang) => ( +// <div +// key={lang.id} +// className="bg-white rounded-xl shadow-sm overflow-hidden hover:shadow-md transition-shadow duration-300" +// > +// <div className="p-6"> +// <div className="flex items-center justify-between mb-4"> +// <div className="flex items-center"> +// <span className="text-3xl mr-3">{lang.flag}</span> +// <h3 className="text-xl font-semibold">{lang.name}</h3> +// </div> +// <span className="text-xs font-semibold bg-indigo-100 text-indigo-800 px-2 py-1 rounded-full"> +// Beginner +// </span> +// </div> +// <div className="mb-4"> +// <div className="w-full bg-gray-200 rounded-full h-2.5 mb-1"> +// <div +// className="bg-indigo-600 h-2.5 rounded-full" +// style={{ width: "40%" }} +// ></div> +// </div> +// <div className="text-right text-sm text-gray-500">40% Complete</div> +// </div> +// <button className="w-full py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors duration-300"> +// Continue Learning +// </button> +// </div> +// </div> +// ))} +// </div>; diff --git a/src/pages/lang/[slug].tsx b/src/pages/lang/[slug].tsx new file mode 100644 index 0000000..11962a5 --- /dev/null +++ b/src/pages/lang/[slug].tsx @@ -0,0 +1,74 @@ +import { Button } from "@/components/ui/button"; +import { Link } from "waku"; + +import { getContextData } from "waku/middleware/context"; +import type { PageProps } from "waku/router"; +import db from "@/lib/db"; +import { Progress } from "@/components/ui/progress"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +const flags: Record<string, string> = { + th: "🇹🇭", + en: "🇬🇧", + zh: "🇨🇳", + ja: "🇯🇵", + es: "🇪🇸", + fr: "🇫🇷", +}; + +export default async function HomePage(props: PageProps<"/lang/[slug]">) { + const lessons = await getData(props.slug); + const { user } = getContextData(); + function commit() {} + + return ( + <> + <section> + <h2 className="text-lg">Thai!</h2> + {lessons.map((l) => ( + <Link key={l.id} to={`/lesson/${l.id}`}> + <Card> + <CardHeader> + <CardTitle> + <h3>{l.name}</h3> + </CardTitle> + <CardDescription> + <p>{l.description}</p> + </CardDescription> + </CardHeader> + <CardContent> + <Progress value={(l.position / l.count) * 100} /> + </CardContent> + </Card> + </Link> + ))} + </section> + </> + ); +} + +const getData = async (lang: string) => { + const lessons = db.fetchLanguage(lang); + + return lessons; +}; + +export const getConfig = async () => { + return { + render: "dynamic", + } as const; +}; + +async function LanguageItem({ lang }: { lang: string }) { + return ( + <div className="flex"> + <div className="text-lg">{flags[lang] || ""}</div> + </div> + ); +} diff --git a/src/pages/lesson/[slug].tsx b/src/pages/lesson/[slug].tsx new file mode 100644 index 0000000..6632838 --- /dev/null +++ b/src/pages/lesson/[slug].tsx @@ -0,0 +1,66 @@ +import { getHonoContext } from "waku/unstable_hono"; +import { Button } from "@/components/ui/button"; +import { Link } from "waku"; + +import { getContext, getContextData } from "waku/middleware/context"; +import * as WServer from "waku/server"; +import type { PageProps } from "waku/router"; +import db from "@/lib/db"; +import { useCookies } from "@/lib/server/cookiebridge"; +import Deck from "@/components/Flashcard/Deck"; + +const flags: Record<string, string> = { + th: "🇹🇭", + en: "🇬🇧", + zh: "🇨🇳", + ja: "🇯🇵", + es: "🇪🇸", + fr: "🇫🇷", +}; + +export default async function HomePage(props: PageProps<"/lesson/[slug]">) { + const hctx: any = getHonoContext(); + console.log({ hctx }); + const ctx = getContext(); + console.log(ctx.req.headers, "heders"); + hctx.set("lol", "lmao"); + const cokis = useCookies(); + const coki = cokis.getCookie("sorlang"); + console.log({ coki }); + console.log({ props }); + // const { user } = getContextData() as any; + // console.log({ user }); + const user = { id: 2 }; + const data = await getData(Number(props.slug), user.id); + if ("error" in data) return <p>Error</p>; + // console.log({ data }); + + return ( + <> + <section> + <h2 className="text-lg">Thai!</h2> + <Deck data={data.ok} /> + </section> + </> + ); +} + +const getData = async (lesson: number, userId: number) => { + const lessons = db.fetchLesson(userId, lesson); + + return lessons; +}; + +export const getConfig = async () => { + return { + render: "dynamic", + } as const; +}; + +async function LanguageItem({ lang }: { lang: string }) { + return ( + <div className="flex"> + <div className="text-lg">{flags[lang] || ""}</div> + </div> + ); +} diff --git a/src/pages/login.tsx b/src/pages/login.tsx index 13d3bd4..d70d365 100644 --- a/src/pages/login.tsx +++ b/src/pages/login.tsx @@ -1,31 +1,18 @@ import AuthScreen from "@/components/Login2"; import ProfileScreen from "@/components/Profile"; -import { Link } from "waku"; -import db from "@/lib/db"; import { getContextData } from "waku/middleware/context"; export default async function AuthPage() { const ctx = getContextData(); - console.log({ ctx }); - const data = await getData(); - if (ctx.user) return <ProfileScreen user={ctx.user as any} />; - else - return ( - <div> - <AuthScreen /> - </div> - ); + // console.log({ ctx }); + // if (ctx.user) return <ProfileScreen user={ctx.user as any} />; + // else + return ( + <div> + <AuthScreen /> + </div> + ); } - -const getData = async () => { - // const data = { - // title: "Waku", - // headline: "Waku", - // body: "Hello world!", - // }; - // return data; -}; - export const getConfig = async () => { return { render: "static", diff --git a/src/pages/logintest/Form.tsx b/src/pages/logintest/Form.tsx new file mode 100644 index 0000000..a593acb --- /dev/null +++ b/src/pages/logintest/Form.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useFormStatus } from "react-dom"; + +const SubmitButton = () => { + const { pending } = useFormStatus(); + return ( + <> + <button + disabled={pending} + type="submit" + className="hover:bg-slate-50 w-fit rounded-lg bg-white p-2" + > + {pending ? "Pending..." : "Submit"} + </button> + </> + ); +}; + +export const Form = ({ + message, + greet, +}: { + message: Promise<string>; + greet: (formData: FormData) => Promise<void>; +}) => ( + <div style={{ border: "3px blue dashed", margin: "1em", padding: "1em" }}> + <p>{message}</p> + <form action={greet}> + <div className="flex flex-col gap-1 text-left"> + <div> + Name:{" "} + <input + name="name" + required + className="invalid:border-red-500 rounded-sm border px-2 py-1" + /> + </div> + <div> + Email:{" "} + <input + type="email" + name="email" + required + className="invalid:border-red-500 rounded-sm border px-2 py-1" + /> + </div> + <SubmitButton /> + </div> + </form> + <h3>This is a client component.</h3> + </div> +); diff --git a/src/pages/logintest/ServerForm.tsx b/src/pages/logintest/ServerForm.tsx new file mode 100644 index 0000000..8e629b8 --- /dev/null +++ b/src/pages/logintest/ServerForm.tsx @@ -0,0 +1,67 @@ +async function submitUserProfile(formData: FormData) { + "use server"; + const name = formData.get("name"); + const age = formData.get("age"); + const favoriteColor = formData.get("favoriteColor"); + const hobby = formData.get("hobby"); + const isSubscribed = formData.get("newsletter") === "on"; + + console.log({ + name, + age, + favoriteColor, + hobby, + isSubscribed, + }); +} + +export const ServerForm = () => { + return ( + <form action={submitUserProfile} className="space-y-4"> + <div style={{ display: "flex", gap: 4, marginBottom: 4 }}> + <label htmlFor="name">Full Name</label> + <input type="text" name="name" id="name" required /> + </div> + + <div style={{ display: "flex", gap: 4, marginBottom: 4 }}> + <label htmlFor="age">Age</label> + <input type="number" name="age" id="age" min="13" max="120" /> + </div> + + <div style={{ display: "flex", gap: 4, marginBottom: 4 }}> + <label htmlFor="favoriteColor">Favorite Color</label> + <select name="favoriteColor" id="favoriteColor"> + <option value="red">Red</option> + <option value="blue">Blue</option> + <option value="green">Green</option> + <option value="purple">Purple</option> + <option value="yellow">Yellow</option> + </select> + </div> + + <div style={{ display: "flex", gap: 4, marginBottom: 4 }}> + <label htmlFor="hobby">Favorite Hobby</label> + <input + type="text" + name="hobby" + id="hobby" + placeholder="e.g. Reading, Gaming, Cooking" + /> + </div> + + <div style={{ display: "flex", gap: 4, marginBottom: 4 }}> + <label> + <input type="checkbox" name="newsletter" /> + Subscribe to newsletter + </label> + </div> + + <button + type="submit" + className="hover:bg-slate-50 w-fit rounded-lg bg-white p-2" + > + Save Profile + </button> + </form> + ); +}; diff --git a/src/pages/logintest/funcs.ts b/src/pages/logintest/funcs.ts new file mode 100644 index 0000000..4ffd5ef --- /dev/null +++ b/src/pages/logintest/funcs.ts @@ -0,0 +1,24 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { unstable_rerenderRoute } from "waku/router/server"; + +export const getMessage = async () => { + const data = await readFile("./message.txt", "utf8"); + return data; +}; + +export const greet = async (formData: FormData) => { + "use server"; + // simulate a slow server response + await new Promise((resolve) => setTimeout(resolve, 1000)); + const currentData = await getMessage(); + await writeFile( + "./message.txt", + currentData + "\n" + formData.get("name") + " from server!", + ); + unstable_rerenderRoute("/"); +}; + +export const increment = async (count: number) => { + "use server"; + return count + 1; +}; diff --git a/src/pages/logintest/index.tsx b/src/pages/logintest/index.tsx new file mode 100644 index 0000000..df8bc08 --- /dev/null +++ b/src/pages/logintest/index.tsx @@ -0,0 +1,24 @@ +import { Form } from "./Form"; +import { getMessage, greet } from "./funcs"; +import { ServerForm } from "./ServerForm"; + +export default function HomePage() { + return ( + <div className="flex h-full w-full flex-col items-center justify-center gap-8 p-6"> + <div className="bg-slate-100 rounded-md p-4"> + <h2 className="text-2xl">Server Form</h2> + <ServerForm /> + </div> + <div className="bg-slate-100 rounded-md p-4"> + <h2 className="text-2xl">Client Form</h2> + <Form message={getMessage()} greet={greet} /> + </div> + </div> + ); +} + +export const getConfig = async () => { + return { + render: "dynamic", + } as const; +}; diff --git a/src/pages/parse.tsx b/src/pages/parse.tsx new file mode 100644 index 0000000..8c0f793 --- /dev/null +++ b/src/pages/parse.tsx @@ -0,0 +1,26 @@ +import { Link } from "waku"; + +import { getContextData } from "waku/middleware/context"; +import ParseForm from "@/components/ParseForm"; + +export default async function HomePage() { + const { user } = getContextData(); + + return <ParseForm />; +} + +const getData = async () => { + const data = { + title: "Waku", + headline: "Waku", + body: "Hello world!", + }; + + return data; +}; + +export const getConfig = async () => { + return { + render: "dynamic", + } as const; +}; diff --git a/src/picker/App.tsx b/src/picker/App.tsx index a3e4f43..0b4d46f 100644 --- a/src/picker/App.tsx +++ b/src/picker/App.tsx @@ -54,7 +54,7 @@ export default function NlpTextAnalysisScreen({ (elementType: GranularityId, elementData: any, elementText: string) => { if (elementType === "word") { startTransition(async () => { - const modal = await wordAction(elementData.text, "en"); + const modal = await wordAction(elementData.lemma, "en"); setModalContent(modal); }); } diff --git a/src/zoom/ServerWord.tsx b/src/zoom/ServerWord.tsx index d98e54b..75b631d 100644 --- a/src/zoom/ServerWord.tsx +++ b/src/zoom/ServerWord.tsx @@ -39,9 +39,9 @@ export default async function Wordd({ lang: string; }) { const data = db.fetchWordBySpelling(word, "en"); - console.log({ data, word }); if (!data) return <p>oh...</p>; + console.log(data.senses[0]); return ( <Card className="overflow-y-scroll max-h-[80vh]"> <CardHeader> @@ -125,10 +125,10 @@ const ExampleDisplay = ({ examples }: { examples: Example[] }) => { <li key={idx} className="text-xs text-gray-600"> <span className="italic">"{ex.text}"</span> {ex.ref && ( - <span className="text-gray-400 text-xxs"> ({ex.ref})</span> + <span className="text-gray-400 text-xs"> ({ex.ref})</span> )} {ex.type !== "quote" && ( - <span className="ml-1 text-xxs bg-sky-100 text-sky-700 px-1 rounded-sm"> + <span className="ml-1 text-xs bg-sky-100 text-sky-700 px-1 rounded-sm"> {ex.type} </span> )} @@ -162,7 +162,7 @@ const RelatedTermsDisplay = ({ {term.word} </a> {/*term.source && ( - <span className="text-xxs text-gray-400"> ({term.source})</span> + <span className="text-xs text-gray-400"> ({term.source})</span> )*/} {idx < terms.length - 1 && ", "} </React.Fragment> @@ -207,7 +207,7 @@ const SubSenseDisplay = ({ {subSense.categories.map((cat, idx) => ( <span key={idx} - className="text-xxs bg-gray-100 text-gray-700 px-1.5 py-0.5 rounded-full" + className="text-xs bg-gray-100 text-gray-700 px-1.5 py-0.5 rounded-full" > {cat} </span> @@ -229,7 +229,7 @@ const SubSenseDisplay = ({ {subSense.tags.map((tag, idx) => ( <span key={idx} - className="text-xxs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded-full" + className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded-full" > {tag} </span> diff --git a/waku.config.ts b/waku.config.ts index 7b4aa09..c1243c4 100644 --- a/waku.config.ts +++ b/waku.config.ts @@ -5,8 +5,8 @@ import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ middleware: [ "waku/middleware/context", - "./src/lib/server/header", - "./src/lib/server/setcookie", + // "./src/lib/server/header", + // "./src/lib/server/setcookie", "./src/lib/server/cookie", "waku/middleware/dev-server", "waku/middleware/handler", |