diff options
author | polwex <polwex@sortug.com> | 2025-05-15 18:47:59 +0700 |
---|---|---|
committer | polwex <polwex@sortug.com> | 2025-05-15 18:47:59 +0700 |
commit | 3d4b740e5a512db8fbdd934af2fbc9585fa00f0f (patch) | |
tree | 0f4fb0549c46f9cc341a72c76a4d834e417f4be4 | |
parent | 05d13b6f166eae5c2de8fe6f6038819b1b6ba1a0 (diff) |
thanks gemini, very pretty
-rw-r--r-- | src/pages/test/client-modal.tsx | 59 | ||||
-rw-r--r-- | src/pages/test/index.tsx | 34 | ||||
-rw-r--r-- | src/pages/test/product-details-server.tsx | 340 | ||||
-rw-r--r-- | src/pages/test/trigger-modal-button.tsx | 37 |
4 files changed, 470 insertions, 0 deletions
diff --git a/src/pages/test/client-modal.tsx b/src/pages/test/client-modal.tsx new file mode 100644 index 0000000..293e28c --- /dev/null +++ b/src/pages/test/client-modal.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useState, ReactNode, useEffect } from "react"; + +interface ClientModalProps { + isOpen: boolean; + onClose: () => void; + children: ReactNode; // This will receive the Server Component's output + title?: string; +} + +export default function ClientModal({ + isOpen, + onClose, + children, + title = "Modal", +}: ClientModalProps) { + // Optional: Prevent body scroll when modal is open + useEffect(() => { + if (isOpen) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = "unset"; + } + return () => { + document.body.style.overflow = "unset"; + }; + }, [isOpen]); + + if (!isOpen) { + return null; + } + + return ( + <div + className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50" + onClick={onClose} // Close on overlay click + > + <div + className="p-6 bg-white rounded-lg shadow-xl w-11/12 max-w-lg" + onClick={(e) => e.stopPropagation()} // Prevent click from closing modal if clicking inside content + > + <div className="flex items-center justify-between mb-4"> + <h2 className="text-xl font-semibold">{title}</h2> + <button + onClick={onClose} + className="text-gray-500 hover:text-gray-700" + aria-label="Close modal" + > + × {/* A simple 'X' close button */} + </button> + </div> + <div> + {children} {/* Server Component content will be rendered here */} + </div> + </div> + </div> + ); +} diff --git a/src/pages/test/index.tsx b/src/pages/test/index.tsx new file mode 100644 index 0000000..35ce5db --- /dev/null +++ b/src/pages/test/index.tsx @@ -0,0 +1,34 @@ +// This is a Server Component by default +import ProductDetailsServer from "./product-details-server"; +import TriggerModalButton from "./trigger-modal-button"; // We'll make this a client component to manage state + +export default function SomePage() { + const productIdForModal = "123"; // Or get this dynamically + + return ( + <main className="container p-8 mx-auto"> + <h1 className="mb-6 text-3xl font-bold"> + Modal with Server Component Content + </h1> + <p> + This page demonstrates opening a modal whose content is rendered by a + Server Component. The modal shell (open/close logic) is a Client + Component. + </p> + + {/* + The TriggerModalButton will manage the modal's open/close state. + It will receive the Server Component as a child to pass to ClientModal. + */} + <TriggerModalButton + modalTitle={`Product Details for ID: ${productIdForModal}`} + > + <ProductDetailsServer word={"fantastic"} /> + </TriggerModalButton> + + <div className="mt-8"> + <p>Other content on the page...</p> + </div> + </main> + ); +} diff --git a/src/pages/test/product-details-server.tsx b/src/pages/test/product-details-server.tsx new file mode 100644 index 0000000..552ff21 --- /dev/null +++ b/src/pages/test/product-details-server.tsx @@ -0,0 +1,340 @@ +// This is a Server Component +import React from "react"; +import db from "@/lib/db"; +import { + Card, + CardHeader, + CardDescription, + CardContent, + CardFooter, + CardTitle, +} from "@/components/ui/card"; +import { NLP } from "sortug-ai"; +import { + BookOpen, + Volume2, + Link as LinkIcon, + ChevronDown, + ChevronUp, + Search, + Info, + MessageSquareQuote, + Tags, + ListTree, + Lightbulb, +} from "lucide-react"; +import { + Example, + SubSense, + RelatedEntry, + Sense, + WordData, +} from "@/zoom/logic/types"; + +export default async function Wordd({ + word, + lang, +}: { + word: string; + lang: string; +}) { + const data = db.fetchWordBySpelling(word, "en"); + console.log({ data }); + + if (!data) return <p>oh...</p>; + return ( + <Card> + <CardHeader> + <CardTitle> + <h1 className="text-xl">{word}</h1> + </CardTitle> + <CardDescription> + <IpaDisplay ipaEntries={data.ipa} /> + </CardDescription> + </CardHeader> + <CardContent> + {/* Senses */} + <h2 className="text-2xl font-semibold text-gray-800 mb-4"> + Meanings & Definitions + </h2> + {data.senses.map((sense, index) => ( + <SenseCard key={index} senseData={sense} senseNumber={index + 1} /> + ))} + </CardContent> + <CardFooter></CardFooter> + </Card> + ); + // return ( + // <div className="p-6"> + // <h3 className="mb-2 text-2xl font-bold">{word}</h3> + // <p className="mb-1 text-xl text-green-600">${word.}</p> + // <p className="text-gray-700">{word}</p> + // <p className="mt-4 text-xs text-gray-500"> + // Content rendered on the server at: {new Date().toLocaleTimeString()} + // </p> + // </div> + // ); +} + +// Helper component for IPA display +const IpaDisplay = ({ + ipaEntries, +}: { + ipaEntries: Array<{ ipa: string; tags?: string[] }>; +}) => { + if (!ipaEntries || ipaEntries.length === 0) return null; + return ( + <div className="flex items-center space-x-2 flex-wrap"> + {ipaEntries.map((entry, index) => { + const tags = entry.tags ? entry.tags : []; + return ( + <span key={index} className="text-lg text-blue-600 font-serif"> + {entry.ipa}{" "} + {tags.length > 0 && ( + <span className="text-xs text-gray-500">({tags.join(", ")})</span> + )} + </span> + ); + })} + <button + className="p-1 text-blue-500 hover:text-blue-700 transition-colors" + title="Pronounce" + // onClick={() => { + // /* Pronunciation logic would be client-side or a server roundtrip for audio file. */ alert( + // "Pronunciation feature not implemented for server component.", + // ); + // }} + > + <Volume2 size={20} /> + </button> + </div> + ); +}; + +// Component for displaying examples +const ExampleDisplay = ({ examples }: { examples: Example[] }) => { + if (!examples || examples.length === 0) return null; + return ( + <div className="mt-2"> + <h5 className="text-xs font-semibold text-gray-600 mb-1 flex items-center"> + <MessageSquareQuote size={14} className="mr-1 text-gray-500" /> + Examples: + </h5> + <ul className="list-disc list-inside pl-2 space-y-1"> + {examples.map((ex, idx) => ( + <li key={idx} className="text-xs text-gray-600"> + <span className="italic">"{ex.text}"</span> + {ex.ref && ( + <span className="text-gray-400 text-xxs"> ({ex.ref})</span> + )} + {ex.type !== "quote" && ( + <span className="ml-1 text-xxs bg-sky-100 text-sky-700 px-1 rounded-sm"> + {ex.type} + </span> + )} + </li> + ))} + </ul> + </div> + ); +}; + +// Component for displaying related terms (synonyms, antonyms, etc.) +const RelatedTermsDisplay = ({ + terms, + type, +}: { + terms: RelatedEntry[] | undefined; + type: string; +}) => { + if (!terms || terms.length === 0) return null; + return ( + <div className="mt-1"> + <span className="text-xs font-semibold text-gray-500 capitalize"> + {type}:{" "} + </span> + {terms.map((term, idx) => ( + <React.Fragment key={idx}> + <a + href={`/search?q=${encodeURIComponent(term.word)}`} + className="text-xs text-blue-500 hover:text-blue-700 hover:underline" + > + {term.word} + </a> + {term.source && ( + <span className="text-xxs text-gray-400"> ({term.source})</span> + )} + {idx < terms.length - 1 && ", "} + </React.Fragment> + ))} + </div> + ); +}; + +// Component for displaying a SubSense +const SubSenseDisplay = ({ + subSense, + subSenseNumber, +}: { + subSense: SubSense; + subSenseNumber: number; +}) => { + return ( + <div className="mb-3 pl-4 border-l-2 border-indigo-200"> + {subSense.glosses.map((gloss, glossIdx) => ( + <p key={glossIdx} className="text-gray-700 mb-1"> + <span className="font-semibold"> + {subSenseNumber}.{glossIdx + 1} + </span>{" "} + {gloss} + </p> + ))} + {subSense.raw_glosses && + subSense.raw_glosses.length > 0 && + subSense.raw_glosses.join("") !== subSense.glosses.join("") && ( + <p className="text-xs text-gray-500 italic mb-1"> + (Raw: {subSense.raw_glosses.join("; ")}) + </p> + )} + + {subSense.categories && subSense.categories.length > 0 && ( + <div className="mt-1 mb-2"> + <h5 className="text-xs font-semibold text-gray-600 mb-0.5 flex items-center"> + <ListTree size={14} className="mr-1 text-gray-500" /> + Categories: + </h5> + <div className="flex flex-wrap gap-1"> + {subSense.categories.map((cat, idx) => ( + <span + key={idx} + className="text-xxs bg-gray-100 text-gray-700 px-1.5 py-0.5 rounded-full" + > + {cat} + </span> + ))} + </div> + </div> + )} + + <ExampleDisplay examples={subSense.examples || []} /> + <RelatedTermsDisplay terms={subSense.synonyms} type="Synonyms" /> + + {subSense.tags && subSense.tags.length > 0 && ( + <div className="mt-2"> + <h5 className="text-xs font-semibold text-gray-600 mb-0.5 flex items-center"> + <Tags size={14} className="mr-1 text-gray-500" /> + Tags: + </h5> + <div className="flex flex-wrap gap-1"> + {subSense.tags.map((tag, idx) => ( + <span + key={idx} + className="text-xxs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded-full" + > + {tag} + </span> + ))} + </div> + </div> + )} + + {subSense.links && subSense.links.length > 0 && ( + <div className="mt-2"> + {subSense.links.map(([type, target], linkIdx) => ( + <a + key={linkIdx} + href={target} // Assuming target is a full URL or a path + target="_blank" + rel="noopener noreferrer" + className="text-xs text-blue-500 hover:text-blue-700 hover:underline mr-2 inline-flex items-center" + > + <LinkIcon size={12} className="mr-1" /> {type} + </a> + ))} + </div> + )} + </div> + ); +}; + +// Component for individual sense +const SenseCard = ({ + senseData, + senseNumber, +}: { + senseData: Sense; + senseNumber: number; +}) => { + return ( + <div className="mb-6 p-4 border border-gray-200 rounded-lg shadow-sm bg-white"> + <div className="flex justify-between items-center mb-2"> + <h3 className="text-xl font-semibold text-indigo-700"> + {senseNumber}. {senseData.pos} + </h3> + </div> + + {senseData.etymology && ( + <details className="mb-3 group"> + <summary className="cursor-pointer flex items-center text-sm text-gray-600 hover:text-indigo-600 transition-colors list-none"> + Etymology + <ChevronDown size={16} className="ml-1 group-open:hidden" /> + <ChevronUp size={16} className="ml-1 hidden group-open:inline" /> + </summary> + <p className="mt-1 text-xs text-gray-500 italic bg-gray-50 p-2 rounded"> + {senseData.etymology} + </p> + </details> + )} + + {senseData.forms && senseData.forms.length > 0 && ( + <div className="mb-3"> + <h4 className="text-sm font-medium text-gray-700">Forms:</h4> + <div className="flex flex-wrap gap-2 mt-1"> + {senseData.forms.map((form, idx) => ( + <span + key={idx} + className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded-full" + > + {form.form}{" "} + {form.tags.length > 0 && `(${form.tags.join(", ")})`} + </span> + ))} + </div> + </div> + )} + + {senseData.senses.map((subSense, idx) => ( + <SubSenseDisplay + key={idx} + subSense={subSense} + subSenseNumber={senseNumber} + /> + ))} + + {senseData.related && ( + <div className="mt-3 pt-3 border-t border-gray-100"> + <h4 className="text-sm font-medium text-gray-700 mb-1 flex items-center"> + <Lightbulb size={16} className="mr-1 text-gray-500" /> + Related Terms: + </h4> + <RelatedTermsDisplay + terms={senseData.related.related} + type="Related" + /> + <RelatedTermsDisplay + terms={senseData.related.synonyms} + type="Synonyms (POS)" + /> + <RelatedTermsDisplay + terms={senseData.related.antonyms} + type="Antonyms (POS)" + /> + <RelatedTermsDisplay + terms={senseData.related.derived} + type="Derived" + /> + </div> + )} + </div> + ); +}; diff --git a/src/pages/test/trigger-modal-button.tsx b/src/pages/test/trigger-modal-button.tsx new file mode 100644 index 0000000..eaa2dda --- /dev/null +++ b/src/pages/test/trigger-modal-button.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useState, ReactNode } from "react"; +import ClientModal from "./client-modal"; // The modal shell + +interface TriggerModalButtonProps { + children: ReactNode; // This will be the <ProductDetailsServer /> + buttonText?: string; + modalTitle?: string; +} + +export default function TriggerModalButton({ + children, + buttonText = "Open Product Details", + modalTitle, +}: TriggerModalButtonProps) { + const [isModalOpen, setIsModalOpen] = useState(false); + + return ( + <> + <button + onClick={() => setIsModalOpen(true)} + className="px-4 py-2 font-semibold text-white bg-indigo-600 rounded-md hover:bg-indigo-700" + > + {buttonText} + </button> + + <ClientModal + isOpen={isModalOpen} + onClose={() => setIsModalOpen(false)} + title={modalTitle || ""} + > + {children} {/* Pass the Server Component content here */} + </ClientModal> + </> + ); +} |