diff --git a/public/llms.txt b/public/llms.txt new file mode 100644 index 00000000..c74626e8 --- /dev/null +++ b/public/llms.txt @@ -0,0 +1,15 @@ +# FiveHive + +> Free AP (Advanced Placement) study resources — study guides, unit notes, and practice questions — written by AP students who scored 5s, for AP students. By AP students. For AP students. + +FiveHive helps high school students prepare for College Board AP exams. Content is organized by subject, then by unit, then by chapter, covering courses such as AP Biology, AP Chemistry, AP Calculus, AP US History, and more. + +## Key pages +- [Subject library](https://www.fivehive.org/library): Browse all AP subjects covered on FiveHive. +- [Study guides](https://www.fivehive.org/guides): Exam study guides and review materials. +- [Apply to contribute](https://www.fivehive.org/apply): Join the FiveHive team of student contributors. + +## Notes +- Subject study guides live at /subject/{subject-slug} and chapters at /subject/{subject-slug}/{unit}/chapter/{id}/{title}. +- Content is free and written/reviewed by AP students. +- See https://www.fivehive.org/sitemap.xml for the full list of indexable pages. diff --git a/src/app/auth/action/page.tsx b/src/app/auth/action/page.tsx index d7b29fb7..5cd2d2ea 100644 --- a/src/app/auth/action/page.tsx +++ b/src/app/auth/action/page.tsx @@ -1,16 +1,19 @@ -'use client'; +"use client"; import PasswordResetPage from "@/components/auth/ResetPassword"; import { useSearchParams } from "next/navigation"; +import { Suspense } from "react"; // If you want this to take effect on your local app, you have to go to firebase -> auth -> templates -> edit -> customize action url -> "http://localhost:/auth/action" -export default function ActionPage() { +function ActionPageContent() { const searchParams = useSearchParams() ?? new URLSearchParams(); const mode = searchParams.get("mode"); const code = searchParams.get("oobCode"); if (!code) { - alert("Need an oobCode to access this page. If you don't know what that means just leave the page"); + alert( + "Need an oobCode to access this page. If you don't know what that means just leave the page", + ); return; } @@ -25,3 +28,11 @@ export default function ActionPage() { ); } } + +export default function ActionPage() { + return ( + + + + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 870f1063..c7379987 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,5 @@ import "@/styles/globals.css"; +import { type Metadata } from "next"; import { Figtree } from "next/font/google"; import RootLayoutClient from "./RootLayoutClient"; import { Toaster } from "@/components/ui/sonner"; @@ -8,9 +9,51 @@ const figtree = Figtree({ variable: "--font-figtree", }); -export const metadata = { - title: "FiveHive", - description: "By AP students. For AP students.", +export const metadata: Metadata = { + metadataBase: new URL("https://www.fivehive.org"), + title: { + default: "FiveHive — Free AP Study Guides, Notes & Practice", + template: "%s | FiveHive", + }, + description: + "Free AP study guides, unit notes, and practice questions written by AP students who scored 5s. Covering AP Biology, Chemistry, Calculus, US History, and more.", + keywords: [ + "AP study guides", + "AP notes", + "AP exam prep", + "AP practice questions", + "free AP resources", + "AP review", + ], + applicationName: "FiveHive", + alternates: { canonical: "/" }, + openGraph: { + type: "website", + siteName: "FiveHive", + title: "FiveHive — Free AP Study Guides, Notes & Practice", + description: + "Free AP study guides, unit notes, and practice questions written by AP students. By AP students. For AP students.", + url: "https://www.fivehive.org", + images: [{ url: "/logo.png", width: 1200, height: 630, alt: "FiveHive" }], + }, + twitter: { + card: "summary_large_image", + title: "FiveHive — Free AP Study Guides, Notes & Practice", + description: + "Free AP study guides, notes, and practice written by AP students.", + images: ["/logo.png"], + }, + robots: { + index: true, + follow: true, + googleBot: { + index: true, + follow: true, + "max-image-preview": "large", + "max-snippet": -1, + "max-video-preview": -1, + }, + }, icons: [{ rel: "icon", url: "/favicon.ico" }], }; diff --git a/src/app/robots.ts b/src/app/robots.ts new file mode 100644 index 00000000..815b41f7 --- /dev/null +++ b/src/app/robots.ts @@ -0,0 +1,40 @@ +import { type MetadataRoute } from "next"; + +const baseUrl = "https://www.fivehive.org"; + +// Authenticated / non-content areas that should not be crawled or cited. +const disallow = [ + "/admin", + "/account", + "/peer-grading", + "/login", + "/signup", + "/auth", + "/api", +]; + +// AI assistant crawlers we explicitly welcome so FiveHive can be cited in +// AI-generated answers. They inherit the same disallow list as everyone else. +const aiBots = [ + "GPTBot", // OpenAI / ChatGPT + "ChatGPT-User", + "OAI-SearchBot", + "PerplexityBot", // Perplexity + "ClaudeBot", // Anthropic / Claude + "anthropic-ai", + "Claude-Web", + "Google-Extended", // Gemini & Google AI Overviews + "Bingbot", // Microsoft Copilot (via Bing) + "Applebot-Extended", +]; + +export default function robots(): MetadataRoute.Robots { + return { + rules: [ + { userAgent: "*", allow: "/", disallow }, + ...aiBots.map((userAgent) => ({ userAgent, allow: "/", disallow })), + ], + sitemap: `${baseUrl}/sitemap.xml`, + host: baseUrl, + }; +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts new file mode 100644 index 00000000..0a7f68a3 --- /dev/null +++ b/src/app/sitemap.ts @@ -0,0 +1,52 @@ +import { type MetadataRoute } from "next"; +import { getAllSubjects } from "@/lib/sitemap-data"; +import { formatSlug } from "@/lib/utils"; + +const baseUrl = "https://www.fivehive.org"; + +export default async function sitemap(): Promise { + const now = new Date(); + + const staticRoutes: MetadataRoute.Sitemap = [ + { path: "", priority: 1 }, + { path: "/library", priority: 0.8 }, + { path: "/guides", priority: 0.8 }, + { path: "/apply", priority: 0.6 }, + { path: "/privacy", priority: 0.3 }, + ].map(({ path, priority }) => ({ + url: `${baseUrl}${path}`, + lastModified: now, + changeFrequency: "weekly", + priority, + })); + + const subjects = await getAllSubjects(); + const subjectRoutes: MetadataRoute.Sitemap = []; + + for (const subject of subjects) { + subjectRoutes.push({ + url: `${baseUrl}/subject/${subject.slug}`, + lastModified: now, + changeFrequency: "weekly", + priority: 0.9, + }); + + subject.units.forEach((unit, unitIndex) => { + const unitSegment = `unit-${unitIndex + 1}-${unit.id}`; + for (const chapter of unit.chapters ?? []) { + // Only list publicly readable chapters — gated content can't be + // crawled or cited anyway, and listing it invites thin-page penalties. + if (chapter.isPublic) { + subjectRoutes.push({ + url: `${baseUrl}/subject/${subject.slug}/${unitSegment}/chapter/${chapter.id}/${formatSlug(chapter.title)}`, + lastModified: now, + changeFrequency: "monthly", + priority: 0.8, + }); + } + } + }); + } + + return [...staticRoutes, ...subjectRoutes]; +} diff --git a/src/app/subject/[slug]/(sidebar)/[unit]/chapter/[id]/[title]/ChapterClient.tsx b/src/app/subject/[slug]/(sidebar)/[unit]/chapter/[id]/[title]/ChapterClient.tsx new file mode 100644 index 00000000..5f3801ce --- /dev/null +++ b/src/app/subject/[slug]/(sidebar)/[unit]/chapter/[id]/[title]/ChapterClient.tsx @@ -0,0 +1,97 @@ +"use client"; +import Renderer from "@/components/article-creator/Renderer"; +import { useFetchAndCache } from "./useFetchAndCache"; +import ChapterScaffold from "./ChapterScaffold"; +import "katex/dist/katex.min.css"; +import { useUser } from "@/components/hooks/UserContext"; +import Link from "next/link"; +import { useEffect } from "react"; + +/** + * Client-side chapter renderer for gated (non-public) chapters: it fetches the + * content through Firestore with the signed-in user's auth, so members/admins can + * preview WIP chapters. Public chapters are server-rendered by `page.tsx` and + * never reach this component. + */ +const ChapterClient = ({ + params, +}: { + params: { slug: string; unit: string; id: string }; +}) => { + const { user } = useUser(); + const { subject, content, loading, error } = useFetchAndCache( + params, + user?.access === "admin" || user?.access === "member", + ); + + useEffect(() => { + if (subject && content) { + const unitIndex = Number(params.unit.split("-")[1]) - 1; + const chapterIndex = subject.units[unitIndex]!.chapters.findIndex( + (ch) => ch.id === params.id, + ); + const chapter = subject.units[unitIndex]!.chapters[chapterIndex]; + + document.title = `FiveHive - ${subject.title} ${unitIndex + 1}.${chapterIndex + 1} - ${chapter?.title}`; + } + }, [subject, content, params.unit, params.id]); + + if (loading) { + return ( +
+ Loading... +
+ ); + } + + if (error) { + return ( +
+

+ {error} +
+ Return to{" "} + + subject homepage + + . +

+
+ ); + } + + if (subject && content) { + const unitIndex = Number(params.unit.split("-")[1]) - 1; + const chapterIndex = subject.units[unitIndex]!.chapters.findIndex( + (ch) => ch.id === params.id, + ); + const chapter = subject.units[unitIndex]!.chapters[chapterIndex]; + const unitTitle = subject.units[unitIndex]?.title; + + if (!unitTitle || !chapter) { + return
Error: Unit or chapter not found.
; + } + + return ( + + + + ); + } + + return null; +}; + +export default ChapterClient; diff --git a/src/app/subject/[slug]/(sidebar)/[unit]/chapter/[id]/[title]/ChapterScaffold.tsx b/src/app/subject/[slug]/(sidebar)/[unit]/chapter/[id]/[title]/ChapterScaffold.tsx new file mode 100644 index 00000000..eab96e6e --- /dev/null +++ b/src/app/subject/[slug]/(sidebar)/[unit]/chapter/[id]/[title]/ChapterScaffold.tsx @@ -0,0 +1,160 @@ +import Footer from "@/components/global/footer"; +import Navbar from "@/components/global/navbar"; +import SubjectBreadcrumb from "@/components/subject/subject-breadcrumb"; +import ProgressTracker from "@/components/subject/progress-tracker"; +import Link from "next/link"; +import type { Unit } from "@/types/firestore"; +import { buttonVariants } from "@/components/ui/button"; +import { cn, formatSlug } from "@/lib/utils"; +import { ArrowLeft, ArrowRight } from "lucide-react"; + +/** + * Presentational chapter shell shared by the public (server-rendered) and gated + * (client) paths so the two never drift. Has no data fetching or hooks of its + * own; the body — server-rendered article or client `Renderer` — is passed as + * `children`. `Navbar`/`ProgressTracker` are client components rendered within. + */ +export default function ChapterScaffold({ + subjectTitle, + units, + unitIndex, + chapterIndex, + unitTitle, + chapterTitle, + chapterId, + author, + children, +}: { + subjectTitle: string; + units: Unit[]; + unitIndex: number; + chapterIndex: number; + unitTitle: string; + chapterTitle: string; + chapterId: string; + author: string; + children: React.ReactNode; +}) { + return ( +
+ + +
+
+ + +

+ {unitIndex + 1}.{chapterIndex + 1} - {chapterTitle} +

+

{author}

+
+ +
+ + {children} +
+
+
+
+ + +
+
+ +
+
+ ); +} + +function PreviousArticle({ + subjectTitle, + units, + unitIndex, + chapterIndex, +}: { + subjectTitle: string; + units: Unit[]; + unitIndex: number; + chapterIndex: number; +}) { + let unit = units[unitIndex]; + let newUnitIndex = unitIndex; + let newChapterIndex = chapterIndex; + + if (chapterIndex <= 0) { + if (unitIndex <= 0) return null; + newUnitIndex -= 1; + unit = units[newUnitIndex]; + if (!unit?.chapters) return null; + newChapterIndex = unit.chapters.length - 1; + } else { + newChapterIndex -= 1; + } + + if (!unit) return null; + + const subjectSlug = formatSlug(subjectTitle.replace(/AP /g, "")); + + return ( + + + Previous Chapter + + ); +} + +function NextArticle({ + subjectTitle, + units, + unitIndex, + chapterIndex, +}: { + subjectTitle: string; + units: Unit[]; + unitIndex: number; + chapterIndex: number; +}) { + let unit = units[unitIndex]; + let newUnitIndex = unitIndex; + let newChapterIndex = chapterIndex; + + if (!unit?.chapters) return null; + + if (chapterIndex >= unit.chapters.length - 1) { + if (unitIndex >= units.length - 1) return null; + newUnitIndex += 1; + unit = units[newUnitIndex]; + newChapterIndex = 0; + } else { + newChapterIndex += 1; + } + + if (!unit) return null; + + const subjectSlug = formatSlug(subjectTitle.replace(/AP /g, "")); + + return ( + + Next Chapter + + + ); +} diff --git a/src/app/subject/[slug]/(sidebar)/[unit]/chapter/[id]/[title]/page.tsx b/src/app/subject/[slug]/(sidebar)/[unit]/chapter/[id]/[title]/page.tsx index edb2fae1..c3f8ba03 100644 --- a/src/app/subject/[slug]/(sidebar)/[unit]/chapter/[id]/[title]/page.tsx +++ b/src/app/subject/[slug]/(sidebar)/[unit]/chapter/[id]/[title]/page.tsx @@ -1,228 +1,70 @@ -"use client"; -import Footer from "@/components/global/footer"; -import Navbar from "@/components/global/navbar"; -import SubjectBreadcrumb from "@/components/subject/subject-breadcrumb"; -import Renderer from "@/components/article-creator/Renderer"; -import { useFetchAndCache } from "./useFetchAndCache"; import "katex/dist/katex.min.css"; -import { useUser } from "@/components/hooks/UserContext"; -import Image from "next/image"; -import Link from "next/link"; -import type { Unit } from "@/types/firestore"; -import { buttonVariants } from "@/components/ui/button"; -import { cn, formatSlug } from "@/lib/utils"; -import { ArrowLeft, ArrowRight } from "lucide-react"; -import { useEffect } from "react"; -import ProgressTracker from "@/components/subject/progress-tracker"; - -const Page = ({ - params, -}: { - params: { slug: string; unit: string; id: string }; -}) => { - const { user } = useUser(); - const { subject, content, loading, error } = useFetchAndCache( - params, - user?.access === "admin" || user?.access === "member", +import { getSubject, getChapterContent } from "@/lib/sitemap-data"; +import { renderEditorJsToHtml } from "@/components/article-creator/editorjs-render"; +import QuestionsHydrator from "@/components/article-creator/QuestionsHydrator"; +import ChapterScaffold from "./ChapterScaffold"; +import ChapterClient from "./ChapterClient"; +import type { OutputData } from "@editorjs/editorjs"; + +const CONTAINER_ID = "chapter-article"; + +type PageParams = { slug: string; unit: string; id: string; title: string }; + +/** + * Chapter page. Public chapters (`isPublic === true`) are server-rendered so the + * study-guide HTML and page-level JSON-LD reach crawlers and AI assistants in the + * initial response. Gated chapters fall through to `ChapterClient`, which fetches + * with the signed-in user's auth. If a public chapter's content can't be read + * server-side (e.g. a transient REST failure), we also fall back to the client + * renderer rather than showing nothing. + */ +export default async function Page({ params }: { params: PageParams }) { + const subject = await getSubject(params.slug); + const unitIndex = Number(params.unit.split("-")[1]) - 1; + const unit = subject?.units[unitIndex]; + const chapterIndex = + unit?.chapters.findIndex((ch) => ch.id === params.id) ?? -1; + const chapter = chapterIndex >= 0 ? unit?.chapters[chapterIndex] : undefined; + + const clientFallback = ( + ); - useEffect(() => { - if (subject && content) { - const unitIndex = Number(params.unit.split("-")[1]) - 1; - const chapterIndex = subject.units[unitIndex]!.chapters.findIndex( - (ch) => ch.id === params.id, - ); - const chapter = subject.units[unitIndex]!.chapters[chapterIndex]; - - document.title = `FiveHive - ${subject.title} ${unitIndex + 1}.${chapterIndex + 1} - ${chapter?.title}`; - } - }, [subject, content, params.unit, params.id]); - - if (loading) { - return ( -
- Loading... -
- ); - } - - if (error) { - return ( -
-

- {error} -
- Return to{" "} - - subject homepage - - . -

-
- ); + if (!subject || !unit || !chapter || chapter.isPublic !== true) { + return clientFallback; } - if (subject && content) { - const unitIndex = Number(params.unit.split("-")[1]) - 1; - const chapterIndex = subject.units[unitIndex]!.chapters.findIndex( - (ch) => ch.id === params.id, - ); - const chapter = subject.units[unitIndex]!.chapters[chapterIndex]; - const unitTitle = subject.units[unitIndex]?.title; - - if (!unitTitle || !chapter) { - return
Error: Unit or chapter not found.
; - } - - return ( -
- - -
-
- - -

- {unitIndex + 1}.{chapterIndex + 1} - {chapter.title} -

-

{content.author}

-
- -
- - -
-
-
-
- - -
-
- -
-
- ); - } else { - error; - } -}; - -function PreviousArticle({ - subjectTitle, - units, - unitIndex, - chapterIndex, -}: { - subjectTitle: string; - units: Unit[]; - unitIndex: number; - chapterIndex: number; -}) { - let unit = units[unitIndex]; - let newUnitIndex = unitIndex; - let newChapterIndex = chapterIndex; - - if (chapterIndex <= 0) { - if (unitIndex <= 0) return null; - newUnitIndex -= 1; - unit = units[newUnitIndex]; - if (!unit?.chapters) return null; - newChapterIndex = unit.chapters.length - 1; - } else { - newChapterIndex -= 1; - } - - if (!unit) return null; - - const subjectSlug = formatSlug(subjectTitle.replace(/AP /g, "")); - - return ( - - - Previous Chapter - - ); -} - -function NextArticle({ - subjectTitle, - units, - unitIndex, - chapterIndex, -}: { - subjectTitle: string; - units: Unit[]; - unitIndex: number; - chapterIndex: number; -}) { - let unit = units[unitIndex]; - let newUnitIndex = unitIndex; - let newChapterIndex = chapterIndex; - - if (!unit?.chapters) return null; - - if (chapterIndex >= unit.chapters.length - 1) { - if (unitIndex >= units.length - 1) return null; - newUnitIndex += 1; - unit = units[newUnitIndex]; - newChapterIndex = 0; - } else { - newChapterIndex += 1; - } - - if (!unit) return null; - - const subjectSlug = formatSlug(subjectTitle.replace(/AP /g, "")); + const content = await getChapterContent(params.slug, params.unit, params.id); + if (!content) return clientFallback; return ( - - Next Chapter - - + + ); } -function AuthorCredits({ - displayName, - photoURL, -}: { - displayName: string; - photoURL: string; -}) { +/** Server-rendered chapter body + client hydration of interactive question cards. */ +function ChapterArticle({ data }: { data: OutputData }) { return ( -
- {`${displayName}'s - {displayName} -
+ <> +
+ + ); } - -export default Page; diff --git a/src/app/subject/[slug]/(sidebar)/layout.tsx b/src/app/subject/[slug]/(sidebar)/layout.tsx index 104dfd7a..198a1f89 100644 --- a/src/app/subject/[slug]/(sidebar)/layout.tsx +++ b/src/app/subject/[slug]/(sidebar)/layout.tsx @@ -8,7 +8,6 @@ import { type Subject } from "@/types/firestore"; import { db } from "@/lib/firebase"; import { doc, getDoc } from "firebase/firestore"; -import Link from "next/link"; import { useUser } from "@/components/hooks/UserContext"; export default function Layout({ @@ -23,8 +22,6 @@ export default function Layout({ const { user } = useUser(); const [subject, setSubject] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); useEffect(() => { const fetchSubject = async () => { @@ -33,14 +30,9 @@ export default function Layout({ const docSnap = await getDoc(docRef); if (docSnap.exists()) { setSubject(docSnap.data() as Subject); - } else { - setError("Subject not found. That's probably us, not you."); } } catch (error) { console.error("Error fetching subject data:", error); - setError("Failed to fetch subject data."); - } finally { - setLoading(false); } }; @@ -49,36 +41,20 @@ export default function Layout({ }); }, [params.slug]); - if (loading) { - return ( -
- Loading... -
- ); - } - - if (error ?? !subject) { - return ( -
-

- {error} -
- Return to{" "} - - FiveHive's homepage - - . -

-
- ); - } - + // `{children}` must always render: this layout wraps server-rendered chapter + // pages, and short-circuiting on the client `loading`/`error` state (which is + // `loading === true` during SSR) would strip the chapter content and its + // page-level JSON-LD out of the static HTML. The sidebar's own subject fetch is + // independent of the page, so a sidebar load failure just omits the rail — the + // page renders (and surfaces any content errors) regardless. return (
- + {subject ? ( + + ) : null} {children}
); diff --git a/src/app/subject/[slug]/layout.tsx b/src/app/subject/[slug]/layout.tsx new file mode 100644 index 00000000..88fee478 --- /dev/null +++ b/src/app/subject/[slug]/layout.tsx @@ -0,0 +1,38 @@ +import { type Metadata } from "next"; +import { getSubjectTitle } from "@/lib/sitemap-data"; + +// Server layout for every page under /subject/[slug]. Its only job is to attach +// real per-subject metadata (title, description, canonical, OpenGraph) so +// crawlers and AI assistants can disambiguate subjects instead of seeing the +// generic global title. The nested (sidebar)/(no-sidebar) client layouts and +// pages render unchanged inside it. +export async function generateMetadata({ + params, +}: { + params: { slug: string }; +}): Promise { + const name = (await getSubjectTitle(params.slug)) ?? "AP Subject"; + const title = `${name} Study Guide & Notes`; + const description = `Free ${name} study guides, unit-by-unit notes, and practice questions written by students who aced the exam. Review every unit of ${name} on FiveHive.`; + const url = `/subject/${params.slug}`; + + return { + title, + description, + alternates: { canonical: url }, + openGraph: { + type: "article", + title: `${title} | FiveHive`, + description, + url, + }, + }; +} + +export default function SubjectLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/src/components/article-creator/QuestionsHydrator.tsx b/src/components/article-creator/QuestionsHydrator.tsx new file mode 100644 index 00000000..985d00c2 --- /dev/null +++ b/src/components/article-creator/QuestionsHydrator.tsx @@ -0,0 +1,77 @@ +"use client"; + +import type { OutputData } from "@editorjs/editorjs"; +import { useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QuestionsOutput } from "./custom_questions/QuestionInstance"; +import type { QuestionFormat } from "@/types/questions"; + +const rootMap = new Map(); + +/** + * Hydrates the interactive question cards inside server- or client-rendered + * chapter markup. The markup (from `renderEditorJsToHtml`) emits an empty + * `.questions-block-` placeholder for every `questionsAddCard` block; + * this component finds each placeholder in the DOM, seeds the question data into + * `localStorage` (the contract `QuestionsOutput` reads from), and mounts a React + * root into it. Renders nothing itself. + * + * `containerId` scopes the placeholder lookup to a single article so multiple + * renderers on a page don't clobber each other. + */ +export default function QuestionsHydrator({ + content, + containerId, +}: { + content: OutputData; + containerId: string; +}) { + useEffect(() => { + const container = document.getElementById(containerId); + if (!container) return; + + const instanceIdsLoaded = new Set(); + + content.blocks.forEach((block) => { + /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */ + if (block.type !== "questionsAddCard") return; + + const instanceId = block.data.instanceId as string; + const storageKey = `questions_${instanceId}`; + + if (!instanceIdsLoaded.has(instanceId)) { + const questionsFromDb: QuestionFormat[] = ( + block.data.questions as QuestionFormat[] + ).map((questionInstance) => ({ + ...questionInstance, + questionInstance: questionInstance.question || { value: "" }, + options: questionInstance.options.map((option) => ({ + ...option, + value: option.value || { value: "" }, + })), + answers: questionInstance.answers || [], + explanation: questionInstance.explanation || { value: "" }, + })); + + localStorage.setItem(storageKey, JSON.stringify(questionsFromDb)); + window.dispatchEvent(new Event("questionsUpdated")); + instanceIdsLoaded.add(instanceId); + } + + const placeholder = container.querySelector( + `.questions-block-${instanceId}`, + ); + if (placeholder) { + let root = rootMap.get(placeholder); + if (!root) { + root = createRoot(placeholder); + rootMap.set(placeholder, root); + } + root.render(); + } + /* eslint-enable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */ + }); + }, [content, containerId]); + + return null; +} diff --git a/src/components/article-creator/editorjs-render.ts b/src/components/article-creator/editorjs-render.ts new file mode 100644 index 00000000..6a1d772d --- /dev/null +++ b/src/components/article-creator/editorjs-render.ts @@ -0,0 +1,263 @@ +import type { OutputData } from "@editorjs/editorjs"; +import type { BlockData, Config } from "editorjs-parser"; +import edjsParser from "editorjs-parser"; +import { decode } from "html-entities"; +import katex from "katex"; +import "katex/contrib/mhchem"; +import hljs from "highlight.js"; +import "@/styles/highlightjs.css"; +import type { QuestionFormat } from "@/types/questions"; +import "@/styles/katexStyling.css"; +import styles from "./Renderer.module.css"; + +// DOM-free entity decoder so this module can run during server rendering. +// Previously this used `document.createElement("textarea")`, which kept the +// whole renderer client-only. `html-entities` decodes named + numeric entities +// without a DOM. +export function decodeEntities(str: string): string { + return decode(str); +} + +export const katexMacros = { + "\\unit": "\\,\\mathrm{#1}", + "\\qty": "#1\\,\\mathrm{#2}", +}; + +// derived from advancedtextbox +function parseLatex(text: string): string { + const decoded = decodeEntities(text); + + return decoded + .split(/(\$@[^$]+\$)/g) + .map((part) => { + if (/^\$@[^$]+\$$/.test(part)) { + const expr = part.slice(2, -1); + return katex.renderToString(expr, { + throwOnError: false, + output: "html", + macros: katexMacros, + }); + } + return part; + }) + .join(""); +} + +const customParsers: Record< + string, + (data: BlockData, _config: Config) => string +> = { + alert: (data, _config) => { + const { align, message, type } = data as { + align: string; + message: string; + type: string; + }; + return `
+
+ ${message} +
+
`; + }, + + code: (data, _config) => { + const { code } = data as { code: string }; + const highlighted = hljs.highlightAuto(code).value; + return `
${highlighted}
`; + }, + + delimiter: (_data, _config) => { + return "
"; + }, + + embed: (data, _config) => { + const { caption, regex, embed, source, height, width } = data as { + caption: string; + regex: string; + embed: string; + source: string; + height: number; + width: number; + }; + return `
+ +
${source}
+
+ +
${caption}
+
`; + }, + + math: (data, _config) => { + const { text } = data as { text: string }; + return katex.renderToString(text, { + output: "html", + throwOnError: true, + displayMode: true, + macros: katexMacros, + }); + }, + + paragraph: (data, _config) => { + const { text } = data as { text: string }; + const parsedText = parseLatex(text); + return `

${parsedText}

`; + }, + + header: (data, _config) => { + const { text, level } = data as { text: string; level: number }; + const lvl = Math.min(Math.max(level || 1, 1), 6); + const parsedText = parseLatex(text); + return `${parsedText}`; + }, + + quote: (data, _config) => { + const { alignment, caption, text } = data as { + alignment: string; + caption: string; + text: string; + }; + return `
+

${text}

+ ${caption} +
`; + }, + + table: (data, _config) => { + const { withHeadings, content } = data as { + withHeadings: boolean; + content: string[][]; + }; + if (content.length === 0) { + return "
"; + } + const rows = content.map((row, index) => { + if (withHeadings && index === 0) { + return `${row.reduce( + (acc, cell) => acc + `${cell}`, + "", + )}`; + } + + // For other rows, use tags + return `${row.reduce( + (acc, cell) => acc + `${cell}`, + "", + )}`; + }); + const thead = withHeadings ? `${rows.shift()}` : ""; + const tbody = `${rows.join("")}`; + + return `${thead}${tbody}
`; + }, + + list: (data, _config) => { + const { style, items, meta } = data; + + const renderItems = (items: typeof data.items, depth = 0): string => { + if (!items || items.length === 0) return ""; + + if (style === "checklist") { + return `
+ ${items + .map((item) => { + const checked = + ("checked" in item.meta && item.meta?.checked) ?? false; + const nested = renderItems(item.items, depth + 1); + return `
+ + ${nested} +
`; + }) + .join("")} +
`; + } + + const tag = style === "ordered" ? "ol" : "ul"; + + // const startAttr = meta?.start ? ` start="${meta.start}"` : ""; + + // const typeAttr = meta?.counterType + // ? ` style="--list-counter-type: ${meta.counterType};"` + // : ""; + + return ` + <${tag} class="depth-${depth}" style="counter-reset: item ${meta?.start ? meta.start - 1 || 1 : ""}; ${meta?.counterType ? `--list-counter-type: ${meta.counterType};` : ""}"> + ${items + .map( + (item) => + `
  • + ${parseLatex(item.content)} + ${renderItems(item.items, depth + 1)} +
  • `, + ) + .join("")} + `; + }; + + return `
    ${renderItems(items)}
    `; + }, + + questionsAddCard: (data, _config) => { + const { instanceId } = data as { + instanceId: string; + content: QuestionFormat; + }; + return `
    `; + }, + + image: (data, _config) => { + // SVGs (viewBox only) collapse or render at the 300x150 replaced-element + // default unless given a definite width, so tag them for the .img-svg rule. + const storageRefPath = data.file?.storageRefFullPath; + const storagePath = + typeof storageRefPath === "string" ? storageRefPath : ""; + const isSvg = + storagePath.toLowerCase().endsWith(".svg") || + (typeof data.url === "string" && data.url.toLowerCase().includes(".svg")); + const imageConditions = `${data.stretched ? "img-fullwidth" : ""} ${ + data.withBorder ? "img-border" : "" + } ${data.withBackground ? "img-bg" : ""} ${data.centerImage ? "img-center" : ""} ${isSvg ? "img-svg" : ""}`; + const imgClass = _config.image.imgClass ?? ""; + let imageSrc; + + if (data.url) { + // simple-image was used and the image probably is not uploaded to this server + // therefore, we use the absolute path provided in data.url + // so, _config.image.path property is useless in this case! + imageSrc = data.url; + } else if (_config.image.path === "absolute") { + imageSrc = data.file?.url; + } else { + imageSrc = _config.image.path?.replace( + /<(.+)>/, + (match, p1: string) => data.file?.[p1] ?? "", + ); + } + + if (_config.image.use === "img") { + return `${data.caption}`; + } else if (_config.image.use === "figure") { + const figureClass = _config.image.figureClass ?? ""; + const figCapClass = _config.image.figCapClass ?? ""; + + return `
    ${data.caption}
    ${data.caption}
    `; + } + return "ERROR DISPLAYING IMAGE"; + }, +}; + +/** + * Parses EditorJS `OutputData` into an HTML string. Pure and DOM-free, so it runs + * both server-side (public chapter SSR) and client-side (gated chapters via + * `Renderer`). Question cards render as empty `.questions-block-` placeholders + * that `QuestionsHydrator` mounts interactive components into on the client. + */ +export function renderEditorJsToHtml(content: OutputData): string { + const parser = new edjsParser(undefined, customParsers); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + return parser.parse(content); +} diff --git a/src/components/hooks/UserContext.tsx b/src/components/hooks/UserContext.tsx index ea5c8077..81a67e2f 100644 --- a/src/components/hooks/UserContext.tsx +++ b/src/components/hooks/UserContext.tsx @@ -53,22 +53,10 @@ export const UserProvider: React.FC<{ children: React.ReactNode }> = ({ await fetchUser(); }; - if (loading) { - return ( -
    - Loading... -
    - ); - } - - if (error) { - return ( -
    - {error} -
    - ); - } - + // Always render children: this provider wraps the whole app, so gating on the + // client-only `loading`/`error` state (which is `loading === true` during SSR) + // strips every page's content and JSON-LD out of the static HTML. Consumers + // read `loading`/`user` from context and handle their own pending/auth state. return ( }; +} + +interface RestDocument { + name: string; + fields?: Record; +} + +function parseValue(value: RestValue): unknown { + if (value.stringValue !== undefined) return value.stringValue; + if (value.booleanValue !== undefined) return value.booleanValue; + if (value.integerValue !== undefined) return Number(value.integerValue); + if (value.doubleValue !== undefined) return value.doubleValue; + if (value.timestampValue !== undefined) return value.timestampValue; + if (value.arrayValue) return (value.arrayValue.values ?? []).map(parseValue); + if (value.mapValue) return parseFields(value.mapValue.fields ?? {}); + return null; +} + +function parseFields( + fields: Record, +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(fields)) { + out[key] = parseValue(value); + } + return out; +} + +export interface SitemapChapter { + id: string; + title: string; + isPublic?: boolean; +} + +export interface SitemapUnit { + id: string; + title: string; + chapters: SitemapChapter[]; +} + +export interface SitemapSubject { + slug: string; + title: string; + units: SitemapUnit[]; +} + +/** + * Returns every subject with its embedded units and chapters. Falls back to an + * empty array on any failure so callers (e.g. the sitemap) still render their + * static entries instead of crashing the build. + */ +export async function getAllSubjects(): Promise { + try { + const res = await fetch(`${BASE}/subjects?pageSize=300&key=${API_KEY}`, { + next: { revalidate: REVALIDATE_SECONDS }, + }); + if (!res.ok) return []; + const data = (await res.json()) as { documents?: RestDocument[] }; + return (data.documents ?? []).map((doc) => { + const slug = doc.name.split("/").pop() ?? ""; + const fields = parseFields(doc.fields ?? {}); + return { + slug, + title: typeof fields.title === "string" ? fields.title : slug, + units: (Array.isArray(fields.units) + ? fields.units + : []) as SitemapUnit[], + }; + }); + } catch { + return []; + } +} + +/** Returns a single subject (title + embedded units), or null on failure. */ +export async function getSubject(slug: string): Promise { + try { + const res = await fetch(`${BASE}/subjects/${slug}?key=${API_KEY}`, { + next: { revalidate: REVALIDATE_SECONDS }, + }); + if (!res.ok) return null; + const data = (await res.json()) as RestDocument; + const fields = parseFields(data.fields ?? {}); + return { + slug, + title: typeof fields.title === "string" ? fields.title : slug, + units: (Array.isArray(fields.units) ? fields.units : []) as SitemapUnit[], + }; + } catch { + return null; + } +} + +/** Returns a single subject's display title, or null if it can't be read. */ +export async function getSubjectTitle(slug: string): Promise { + return (await getSubject(slug))?.title ?? null; +} + +export interface ChapterContent { + data: OutputData; + author: string; + displayName: string; + title: string; +} + +/** + * Firestore stores table block content as a map (`{ row0: [...], row1: [...] }`) + * because nested arrays aren't allowed. EditorJS expects an array of rows, so + * convert every table block back. Mirrors `revertTableObjectToArray`, but kept + * here so this server module doesn't import the client-only article helpers. + */ +function revertTableBlocks(data: OutputData): void { + for (const block of data.blocks ?? []) { + if (block.type !== "table") continue; + const blockData = block.data as { content?: unknown }; + const content = blockData.content; + if (content && typeof content === "object" && !Array.isArray(content)) { + const rows = content as Record; + blockData.content = Object.keys(rows) + .sort() + .map((key) => rows[key]); + } + } +} + +/** + * Reads a single chapter's content document via the Firestore REST API. The + * `chapters` rule allows unauthenticated reads only when `isPublic == true`, so a + * successful read here means the chapter is public and safe to server-render; + * gated chapters return 403 and this resolves to `null`. Doc-id mapping mirrors + * the client `useFetchAndCache` hook. + */ +export async function getChapterContent( + slug: string, + unitParam: string, + idParam: string, +): Promise { + try { + const unitId = unitParam.split("-").at(-1); + const chapterId = idParam.split("-").slice(0, 2).join("-"); + if (!unitId || !chapterId) return null; + + const res = await fetch( + `${BASE}/subjects/${slug}/units/${unitId}/chapters/${chapterId}?key=${API_KEY}`, + { next: { revalidate: REVALIDATE_SECONDS } }, + ); + if (!res.ok) return null; + + const doc = (await res.json()) as RestDocument; + const fields = parseFields(doc.fields ?? {}); + const data = fields.data as OutputData | undefined; + if (!data || !Array.isArray(data.blocks)) return null; + + revertTableBlocks(data); + + return { + data, + author: typeof fields.author === "string" ? fields.author : "", + displayName: + typeof fields.displayName === "string" ? fields.displayName : "", + title: typeof fields.title === "string" ? fields.title : "", + }; + } catch { + return null; + } +} \ No newline at end of file