Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions public/llms.txt
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 14 additions & 3 deletions src/app/auth/action/page.tsx
Original file line number Diff line number Diff line change
@@ -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:<port>/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;
}

Expand All @@ -25,3 +28,11 @@ export default function ActionPage() {
);
}
}

export default function ActionPage() {
return (
<Suspense>
<ActionPageContent />
</Suspense>
);
}
49 changes: 46 additions & 3 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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" }],
};

Expand Down
40 changes: 40 additions & 0 deletions src/app/robots.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
52 changes: 52 additions & 0 deletions src/app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -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<MetadataRoute.Sitemap> {
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];
}
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex min-h-screen grow items-center justify-center text-2xl">
Loading...
</div>
);
}

if (error) {
return (
<div className="grid min-h-screen grow place-content-center text-xl">
<p>
{error}
<br />
Return to{" "}
<Link
href={`/subject/${params.slug}`}
className="text-blue-600 hover:underline"
>
subject homepage
</Link>
.
</p>
</div>
);
}

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 <div>Error: Unit or chapter not found.</div>;
}

return (
<ChapterScaffold
subjectTitle={subject.title}
units={subject.units}
unitIndex={unitIndex}
chapterIndex={chapterIndex}
unitTitle={unitTitle}
chapterTitle={chapter.title}
chapterId={params.id}
author={content.author}
>
<Renderer content={content.data} />
</ChapterScaffold>
);
}

return null;
};

export default ChapterClient;
Loading
Loading