diff --git a/firestore.rules b/firestore.rules index 61f17e7c..4d074ea0 100644 --- a/firestore.rules +++ b/firestore.rules @@ -99,5 +99,12 @@ service cloud.firestore { match /profiles/{userId} { allow read, write: if isAuthenticated() && request.auth.uid == userId; } + + match /feedback/{feedbackId} { + allow read: if isMemberOrAdmin; + allow list: if isMemberOrAdmin; + allow get: if isMemberOrAdmin(); + allow update, delete: if isMemberOrAdmin(); + } } } diff --git a/package-lock.json b/package-lock.json index 851bbd67..0103dc5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "@types/katex": "^0.16.7", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "dompurify": "^3.4.11", "dotenv": "^16.4.5", "editorjs-alert": "^1.1.4", "editorjs-latex": "^1.0.5", @@ -47,6 +48,7 @@ "highlight.js": "^11.10.0", "katex": "^0.16.11", "lucide-react": "^0.525.0", + "marked": "^18.0.5", "next": "^14.2.5", "next-themes": "^0.4.4", "obscenity": "^0.4.3", @@ -2720,8 +2722,7 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/@types/uuid": { "version": "10.0.0", @@ -4552,11 +4553,10 @@ } }, "node_modules/dompurify": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", - "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -7658,16 +7658,15 @@ "license": "ISC" }, "node_modules/marked": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", - "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/math-intrinsics": { @@ -7816,6 +7815,29 @@ "marked": "14.0.0" } }, + "node_modules/monaco-editor/node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/monaco-editor/node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/morgan": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.9.1.tgz", diff --git a/package.json b/package.json index e180b510..9ba74ca1 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@types/katex": "^0.16.7", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "dompurify": "^3.4.11", "dotenv": "^16.4.5", "editorjs-alert": "^1.1.4", "editorjs-latex": "^1.0.5", @@ -50,6 +51,7 @@ "highlight.js": "^11.10.0", "katex": "^0.16.11", "lucide-react": "^0.525.0", + "marked": "^18.0.5", "next": "^14.2.5", "next-themes": "^0.4.4", "obscenity": "^0.4.3", 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/account/page.tsx b/src/app/account/page.tsx index a742a559..c51531a5 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -1,4 +1,5 @@ "use client"; +/* eslint-disable @typescript-eslint/no-unused-vars */ import "@/styles/globals.css"; import React, { useEffect, useState, type FormEvent } from "react"; diff --git a/src/app/admin/feedback/[slug]/FeedbackDetailsClient.tsx b/src/app/admin/feedback/[slug]/FeedbackDetailsClient.tsx new file mode 100644 index 00000000..905bd0e4 --- /dev/null +++ b/src/app/admin/feedback/[slug]/FeedbackDetailsClient.tsx @@ -0,0 +1,308 @@ +/* eslint-disable */ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { db } from '@/lib/firebase'; +import { doc, getDoc, updateDoc, setDoc } from 'firebase/firestore'; +import { marked } from 'marked'; +import DOMPurify from 'dompurify'; + +interface FeedbackDetailsClientProps { + slug: string; + config: { + gitHubAccessToken: string; + gitHubRepoOwner: string; + gitHubRepoName: string; + }; +} + +function parseMarkdown(text: string): string { + if (!text) return ''; + const rawHtml = marked(text) as string; + return DOMPurify.sanitize(rawHtml); +} + +interface FeedbackItem { + type: string; + title: string; + bugType?: string; + bugUrl?: string; + message: string; + email: string; + attachedImage?: string; + isResolved?: boolean; + createdAt?: { + seconds: number; + nanoseconds: number; + }; + // Feature request specific fields + featureProblem?: string; + featureAlternatives?: string; + featureSolution?: string; + featureContextUrl?: string; +} + +export default function FeedbackDetailsClient({ slug, config }: FeedbackDetailsClientProps) { + const router = useRouter(); + const [bug, setBug] = useState(null); + const [loading, setLoading] = useState(true); + const [isCreatingIssue, setIsCreatingIssue] = useState(false); + const [submitted, setSubmitted] = useState(false); + + useEffect(() => { + if (!slug) return; + + const fetchSingleFeedback = async () => { + try { + const docRef = doc(db, 'feedback', slug); + const docSnap = await getDoc(docRef); + + if (docSnap.exists()) { + setBug(docSnap.data() as FeedbackItem); + } else { + console.error("No such document found!"); + } + } catch (err) { + console.error("Error pulling document context:", err); + } finally { + setLoading(false); + } + }; + + fetchSingleFeedback(); + }, [slug]); + + const handleCreateGitHubIssue = async () => { + if (!bug) return; + if (!config.gitHubAccessToken || !config.gitHubRepoOwner || !config.gitHubRepoName) { + alert("GitHub integration is not fully configured. Please check your server environment variables."); + return; + } + + setIsCreatingIssue(true); + + try { + const owner = config.gitHubRepoOwner; + const repo = config.gitHubRepoName; + const url = `https://api.github.com/repos/${owner}/${repo}/issues`; + + const imgMarkdown = bug.attachedImage && bug.attachedImage.startsWith('http') ? `\n\n[Attached Image](${bug.attachedImage})` : ''; + + let issueBody = `### Feedback Context\n\n- **Type:** ${bug.type}\n- **Contact Email:** ${bug.email ?? 'anonymous'}\n`; + if (bug.bugUrl && bug.bugUrl !== 'N/A') { + issueBody += `- **Context URL:** ${bug.bugUrl}\n`; + } + if (bug.type === 'bug') { + issueBody += `- **Category:** ${bug.bugType ?? 'N/A'}\n`; + issueBody += `\n### Detailed Description & Steps to Reproduce\n\n${bug.message}\n${imgMarkdown}`; + } else if (bug.type === 'feature') { + issueBody += `\n### Feature Request Details\n`; + if (bug.featureProblem) issueBody += `- **Problem:** ${bug.featureProblem}\n`; + if (bug.featureAlternatives) issueBody += `- **Alternatives Considered:** ${bug.featureAlternatives}\n`; + if (bug.featureSolution) issueBody += `- **Proposed Solution:** ${bug.featureSolution}\n`; + issueBody += `\n### Additional Description\n\n${bug.message}\n${imgMarkdown}`; + } else { + issueBody += `\n### Description\n\n${bug.message}${imgMarkdown}`; + } + + const title = bug.title && bug.title.trim().length > 0 ? bug.title : `${bug.type === 'bug' ? 'Bug' : 'Feature'}: ${bug.bugType || ''}`; + const payload = { + title, + body: issueBody.trim(), + labels: [bug.type === 'bug' ? 'bug' : 'enhancement'], + }; + console.log('Creating GitHub issue with payload:', payload); + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${config.gitHubAccessToken}`, + 'Accept': 'application/vnd.github.v3+json', + }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) as any; + const detailed = errorData.errors?.map((e: any) => `${e.resource || ''} ${e.field || ''} ${e.code || ''}`).join(', '); + const msg = errorData.message ?? `GitHub API responded with status ${response.status}`; + throw new Error(detailed ? `${msg}: ${detailed}` : msg); + } + + // Update Firestore document resolution status to resolved + const docRef = doc(db, 'feedback', slug); + await setDoc(docRef, { isResolved: true }, { merge: true }); + + alert("GitHub Issue created successfully!"); + setSubmitted(true); + } catch (err: unknown) { + const errorMsg = err instanceof Error ? err.message : String(err); + console.error("Failed to create GitHub issue:", err); + alert(`Error creating GitHub issue: ${errorMsg}`); + } finally { + setIsCreatingIssue(false); + } + }; + + if (loading) return
Loading item snapshot...
; + if (!bug) return
Feedback ticket not found.
; + if (submitted) { + return ( +
+

Feedback Submitted

+

Your feedback has been turned into a GitHub issue and marked as resolved.

+ +
+ ); + } + + return ( +
+ + +
+

Feedback Record

+
+ {bug.isResolved ? ( + + Resolved + + ) : ( + + Active + + )} + + {bug.type} + +
+
+ +
+
+

+ Title +

+
+ {bug.title} +
+
+ + {bug.type === 'bug' && ( + <> +
+

Issue Category

+

{bug.bugType}

+
+ +
+

Context URL

+ {bug.bugUrl && bug.bugUrl !== 'N/A' ? ( + + {bug.bugUrl} + + ) : ( +

N/A

+ )} +
+ + )} + {bug.type === 'feature' && ( + <> +
+

Context URL

+ {bug.featureContextUrl ? ( + + {bug.featureContextUrl} + + ) : ( +

N/A

+ )} +
+
+

Problem Description

+

{bug.featureProblem || 'N/A'}

+
+
+

Alternatives Considered

+

{bug.featureAlternatives || 'N/A'}

+
+
+

Proposed Solution

+

{bug.featureSolution || 'N/A'}

+
+ + )} + +
+

+ {bug.type === 'bug' ? 'Detailed Description & Steps' : 'User Message'} +

+ {/* Content Report Container with native Markdown parser applied */} +
+
+ + {bug.attachedImage && ( +
+

Attached Image

+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Attached Screenshot +
+
+ )} + +
+

Contact Email: {bug.email}

+

+ Timestamp: {bug.createdAt?.seconds + ? new Date(bug.createdAt.seconds * 1000).toLocaleString() + : 'N/A' + } +

+
+ + {(bug.type === 'bug' || bug.type === 'feature') && !bug.isResolved && ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/app/admin/feedback/[slug]/page.tsx b/src/app/admin/feedback/[slug]/page.tsx new file mode 100644 index 00000000..d0bd8311 --- /dev/null +++ b/src/app/admin/feedback/[slug]/page.tsx @@ -0,0 +1,17 @@ +import FeedbackDetailsClient from './FeedbackDetailsClient'; + +interface PageProps { + params: { + slug: string; + }; +} + +export default function FeedbackDetailsPage({ params }: PageProps) { + const config = { + gitHubAccessToken: process.env.GITHUB_ACCESS_TOKEN ?? '', + gitHubRepoOwner: process.env.GITHUB_REPO_OWNER ?? '', + gitHubRepoName: process.env.GITHUB_REPO_NAME ?? '', + }; + + return ; +} \ No newline at end of file diff --git a/src/app/admin/feedback/page.tsx b/src/app/admin/feedback/page.tsx new file mode 100644 index 00000000..0a8c1509 --- /dev/null +++ b/src/app/admin/feedback/page.tsx @@ -0,0 +1,167 @@ +"use client"; +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-floating-promises, @typescript-eslint/no-explicit-any */ + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { db } from '@/lib/firebase'; +import { collection, getDocs, query, orderBy, doc, updateDoc } from 'firebase/firestore'; +import { buttonVariants } from "@/components/ui/button"; +import { ArrowLeft, Loader2 } from "lucide-react"; +import { useRouter } from 'next/navigation'; + +export default function AdminFeedbackDashboard() { + const router = useRouter(); + const [feedbackItems, setFeedbackItems] = useState([]); + const [activeTab, setActiveTab] = useState<'active' | 'resolved'>('active'); + const [loading, setLoading] = useState(true); + + const handleResolve = async (e: React.MouseEvent, itemId: string, item: any) => { + e.stopPropagation(); + setArchivingIds(prev => [...prev, itemId]); + try { + const docRef = doc(db, 'feedback', itemId); + const nextStatus = !item.isResolved; + await updateDoc(docRef, { isResolved: nextStatus }); + + setFeedbackItems(prevItems => + prevItems.map(item => + item.id === itemId ? { ...item, isResolved: nextStatus } : item + ) + ); + } + catch (error) { + console.error("Error archiving item:", error); + } + finally { + setArchivingIds(prev => prev.filter(id => id !== item.id)); + } + }; + + const activeItems = feedbackItems.filter(item => !item.isResolved); + const resolvedItems = feedbackItems.filter(item => item.isResolved); + const displayedItems = activeTab === 'active' ? activeItems : resolvedItems; + const [archivingIds, setArchivingIds] = useState([]); + + useEffect(() => { + const fetchFeedback = async () => { + try { + const q = query(collection(db, 'feedback'), orderBy('createdAt', 'desc')); + const querySnapshot = await getDocs(q); + const items = querySnapshot.docs.map(doc => ({ + id: doc.id, + ...doc.data() + })); + setFeedbackItems(items); + } catch (error) { + console.error("Error fetching feedback:", error); + } finally { + setLoading(false); + } + }; + + fetchFeedback(); + }, []); + + if (loading) return
Loading feedback...
; + + return ( +
+ + + Return to Admin Dashboard + +

Feedback/Bug Reports

+ +
+ + +
+ {displayedItems.length === 0 ? ( +

+ {activeTab === 'active' ? 'No active feedback submissions found.' : 'No resolved submissions found yet.'} +

+ ) : ( +
+ + + + + + + + + + + + {displayedItems.map((item) => ( + router.push(`/admin/feedback/${item.id}`)} className="hover:bg-gray-50 transition-colors"> + + + + + + + + ))} + +
TypeTitleSubmitted ByDate
+ + {item.type} + + + + {item.title} + {item.type === 'bug' ? ` (${item.bugType})` : ''} + + {item.email} + {item.createdAt?.seconds + ? new Date(item.createdAt.seconds * 1000).toLocaleDateString() + : new Date().toLocaleDateString() + } + e.stopPropagation()}> + +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 43ecd0cd..357e818a 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,4 +1,6 @@ "use client"; +/* eslint-disable @typescript-eslint/no-unused-vars */ + import Navbar from "@/components/global/navbar"; import Footer from "@/components/global/footer"; import type { User } from "@/types/user"; @@ -10,12 +12,11 @@ import { useUser } from "../../components/hooks/UserContext"; import Link from "next/link"; import { cn, formatSlug } from "@/lib/utils"; import { Ban, ClipboardPen, PencilRuler, ShieldUser, X } from "lucide-react"; -import AdminImport from "@/components/admin/AdminImport"; import { doc, updateDoc } from "firebase/firestore"; import { db } from "@/lib/firebase"; import { Button } from "@/components/ui/button"; -const apClasses = apClassesData.apClasses; +const apClasses = [...apClassesData.apClasses, "AP Porting"]; const Page = () => { const { user } = useUser(); @@ -44,11 +45,16 @@ const Page = () => { {user.access === "admin" && ( <> -

Validate Editor JSON

- )} + + + + +

@@ -121,11 +127,9 @@ function AdminPanel({ user }: { user: User }) { const filteredUsers = users.filter( (user) => - user.displayName - .toLowerCase() - .includes(searchTermUsers.toLowerCase().trim()) || - user.email.toLowerCase().includes(searchTermUsers.toLowerCase().trim()) || - user.access.toLowerCase().includes(searchTermUsers.toLowerCase().trim()), + user.displayName?.toLowerCase().includes(searchTermUsers.toLowerCase().trim()) || + user.email?.toLowerCase().includes(searchTermUsers.toLowerCase().trim()) || + user.access?.toLowerCase().includes(searchTermUsers.toLowerCase().trim()), ); return ( diff --git a/src/app/admin/subject/[slug]/[unit]/chapter/[id]/page.tsx b/src/app/admin/subject/[slug]/[unit]/chapter/[id]/page.tsx index fc3fd585..b50870f3 100644 --- a/src/app/admin/subject/[slug]/[unit]/chapter/[id]/page.tsx +++ b/src/app/admin/subject/[slug]/[unit]/chapter/[id]/page.tsx @@ -1,9 +1,5 @@ "use client"; -import dynamic from "next/dynamic"; -const ArticleCreator = dynamic( - () => import("@/components/article-creator/ArticleCreator"), - { ssr: false }, -); +import ArticleCreator from "@/components/article-creator/ArticleCreator"; import { useUser } from "@/components/hooks/UserContext"; import { buttonVariants } from "@/components/ui/button"; import { ArrowLeft, ExternalLink, UserRoundCog } from "lucide-react"; @@ -18,8 +14,9 @@ const Page = () => { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); + const query = searchParams ?? new URLSearchParams(); - const pathParts = pathname.split("/").slice(-4); + const pathParts = (pathname ?? "").split("/").slice(-4); useEffect(() => { if ((!user || user?.access === "user") && !loading) { @@ -28,7 +25,7 @@ const Page = () => { }, [user, loading, router]); return ( -
+
{ href={`/subject/${pathParts[0]}`} className="flex justify-center gap-1" > - {decodeURIComponent(searchParams.get("subject") ?? "")} Unit{" "} - {decodeURIComponent(searchParams.get("unitIndex") ?? "")} + {decodeURIComponent(query.get("subject") ?? "")} Unit{" "} + {decodeURIComponent(query.get("unitIndex") ?? "")} - {decodeURIComponent(searchParams.get("chapter") ?? "")} + {decodeURIComponent(query.get("chapter") ?? "")} diff --git a/src/app/admin/subject/[slug]/[unit]/test/[id]/page.tsx b/src/app/admin/subject/[slug]/[unit]/test/[id]/page.tsx index 04fcaf18..d4a214a4 100644 --- a/src/app/admin/subject/[slug]/[unit]/test/[id]/page.tsx +++ b/src/app/admin/subject/[slug]/[unit]/test/[id]/page.tsx @@ -21,7 +21,7 @@ import { cn } from "@/lib/utils"; import { Blocker } from "@/app/admin/subject/navigation-block"; const Page = () => { - const pathname = usePathname(); + const pathname = usePathname() ?? ""; const { user } = useUser(); const instanceId = pathname.split("/").slice(-4).join("_"); diff --git a/src/app/admin/subject/[slug]/page.tsx b/src/app/admin/subject/[slug]/page.tsx index 3fb60a73..374ac4c4 100644 --- a/src/app/admin/subject/[slug]/page.tsx +++ b/src/app/admin/subject/[slug]/page.tsx @@ -20,6 +20,7 @@ import { cn, formatSlug } from "@/lib/utils"; import short from "short-uuid"; import type { Subject, Unit } from "@/types/firestore"; import UnitComponent from "./_components/unit"; +import { DEFAULT_PORTING_SUBJECT } from "@/lib/apPortingDefaults"; const translator = short(short.constants.flickrBase58); @@ -29,7 +30,7 @@ function generateShortId() { return timestamp + randomPart; } -const apClasses = apClassesData.apClasses; +const apClasses = [...apClassesData.apClasses, "AP Porting"]; // Default empty subject structure const emptyData: Subject = { @@ -68,6 +69,7 @@ export default function Page({ params }: { params: { slug: string } }) { const { user, error, setError, setLoading } = useUser(); const [subjectTitle, setSubjectTitle] = useState(""); const [units, setUnits] = useState([]); + const [resetting, setResetting] = useState(false); const [subjectLoading, setSubjectLoading] = useState(true); const [unsavedChanges, setUnsavedChanges] = useState(false); @@ -172,6 +174,85 @@ export default function Page({ params }: { params: { slug: string } }) { * If you dont want this, use the commented out handleSave. ****************************************************/ + const handleReset = async () => { + if (!confirm("Are you sure you want to reset the AP Porting course? This will restore all articles, MCQs, and structure to their default values and delete any custom changes you made.")) { + return; + } + setResetting(true); + try { + // 1. Fetch and delete existing structure/articles/tests under /subjects/porting + const unitsCollectionRef = collection(db, "subjects", "porting", "units"); + const unitsSnap = await getDocs(unitsCollectionRef); + + const batch = writeBatch(db); + + for (const unitDoc of unitsSnap.docs) { + const chaptersCollectionRef = collection(db, "subjects", "porting", "units", unitDoc.id, "chapters"); + const chaptersSnap = await getDocs(chaptersCollectionRef); + chaptersSnap.forEach((chapterDoc) => { + batch.delete(chapterDoc.ref); + }); + + const testsCollectionRef = collection(db, "subjects", "porting", "units", unitDoc.id, "tests"); + const testsSnap = await getDocs(testsCollectionRef); + testsSnap.forEach((testDoc) => { + batch.delete(testDoc.ref); + }); + + batch.delete(unitDoc.ref); + } + + batch.delete(doc(db, "subjects", "porting")); + await batch.commit(); + + // 2. Write defaults + const writeBatchObj = writeBatch(db); + writeBatchObj.set(doc(db, "subjects", "porting"), { + title: DEFAULT_PORTING_SUBJECT.title, + units: DEFAULT_PORTING_SUBJECT.units, + }); + + for (const unit of DEFAULT_PORTING_SUBJECT.units) { + const unitRef = doc(db, "subjects", "porting", "units", unit.id); + writeBatchObj.set(unitRef, unit); + + for (const chapter of unit.chapters) { + const chapterRef = doc(db, "subjects", "porting", "units", unit.id, "chapters", chapter.id); + writeBatchObj.set(chapterRef, { + id: chapter.id, + createdAt: new Date(), + author: "System Default", + title: `${unit.id}/chapter/${chapter.id}`, + isPublic: chapter.isPublic ?? true, + }); + } + + if (unit.tests) { + for (const test of unit.tests) { + const testRef = doc(db, "subjects", "porting", "units", unit.id, "tests", test.id); + writeBatchObj.set(testRef, { + ...test, + instanceId: `porting_${unit.id}_test_${test.id}`, + }); + } + } + } + + await writeBatchObj.commit(); + + setSubjectTitle(DEFAULT_PORTING_SUBJECT.title); + setUnits(DEFAULT_PORTING_SUBJECT.units); + setUnsavedChanges(false); + + alert("AP Porting course reset successfully."); + } catch (error) { + console.error("Error resetting subject:", error); + alert("Error resetting subject. Check console for details."); + } finally { + setResetting(false); + } + }; + // Replaces save const handleSave = async () => { // Rebuild the Subject object from current state @@ -353,17 +434,28 @@ export default function Page({ params }: { params: { slug: string } }) { Return to Admin Dashboard - {(!subjectLoading || unsavedChanges) && ( - - )} +
+ {params.slug === "porting" && ( + + )} + {(!subjectLoading || unsavedChanges) && ( + + )} +
{/* Subject Title */} diff --git a/src/app/auth/action/page.tsx b/src/app/auth/action/page.tsx index 0019a9b3..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() { - const searchParams = useSearchParams(); +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/feedback/page.tsx b/src/app/feedback/page.tsx new file mode 100644 index 00000000..29c99d92 --- /dev/null +++ b/src/app/feedback/page.tsx @@ -0,0 +1,334 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-floating-promises, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unused-vars, react/no-unescaped-entities */ +'use client' + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { storage } from '@/lib/firebase'; +import { db } from '@/lib/firebase'; +import { ref, uploadBytes, getDownloadURL } from 'firebase/storage'; +import { collection, addDoc, serverTimestamp, Timestamp } from 'firebase/firestore'; + +export default function FeedbackPage() { + const [featureProblem, setFeatureProblem] = useState(''); + const [featureAlternatives, setFeatureAlternatives] = useState(''); + const [featureSolution, setFeatureSolution] = useState(''); + const [submitted, setSubmitted] = useState(false); + const [type, setType] = useState('general'); + // New bug-specific states matching your images + const [bugType, setBugType] = useState('Website Article Errors'); + const [bugUrl, setBugUrl] = useState(''); + const [attachedImage, setAttachedImage] = useState(null); + const [featureContextUrl, setFeatureContextUrl] = useState(''); + const [message, setMessage] = useState(''); + const [email, setEmail] = useState(''); + const [title, setTitle] = useState(''); + const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); + + const handleImageChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setAttachedImage(file); + } else { + setAttachedImage(null); + } + }; + + const router = useRouter(); + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const trimmedTitle = title.trim(); + const trimmedMessage = message.trim(); + const trimmedBugUrl = bugUrl.trim(); + + if (!trimmedTitle || !trimmedMessage) { + setStatus('error'); + return; + } + + if (type === 'bug' && !trimmedBugUrl) { + setStatus('error'); + return; + } + + setStatus('loading'); + try { + interface NewFeedback { + type: string; + message: string; + title: string; + email: string; + // Firestore server timestamp field + createdAt: Timestamp; + bugType?: string; + bugUrl?: string; + attachedImage?: string; + // Feature request fields + featureProblem?: string; + featureAlternatives?: string; + featureSolution?: string; + featureContextUrl?: string; + } + + // Build the base payload + const feedbackPayload: NewFeedback = { + type, + message: trimmedMessage, + title: trimmedTitle, + email: email || 'anonymous', + createdAt: serverTimestamp() as Timestamp, + }; + + // Conditionally append the precise details if it's a bug report + if (type === 'bug') { + feedbackPayload.bugType = bugType; + feedbackPayload.bugUrl = trimmedBugUrl; + } + // Add feature request specific fields + if (type === 'feature') { + feedbackPayload.featureProblem = featureProblem; + feedbackPayload.featureAlternatives = featureAlternatives; + feedbackPayload.featureSolution = featureSolution; + feedbackPayload.featureContextUrl = featureContextUrl; + } + + // If an image was attached, upload it to Firebase Storage first + if (attachedImage && type !== 'general') { + const imageRef = ref( + storage, + `feedbackImages/${Date.now()}-${attachedImage.name}` + ); + await uploadBytes(imageRef, attachedImage); + feedbackPayload.attachedImage = await getDownloadURL(imageRef); + } + + // Add feedback document with image URL (if any) included + const docRef = await addDoc(collection(db, 'feedback'), feedbackPayload); + // Mark as submitted so UI shows success view + setSubmitted(true); + setStatus('success'); + console.log('Feedback document created with ID:', docRef.id); + + // Clear form fields + setMessage(''); + setEmail(''); + setTitle(''); + setBugUrl(''); + setAttachedImage(null); + setFeatureProblem(''); + setFeatureAlternatives(''); + setFeatureSolution(''); + // Reset file input if present + const fileInput = document.getElementById('screenshot-input') as HTMLInputElement; + if (fileInput) fileInput.value = ''; + } catch (err) { + console.error('Error submitting feedback:', err); + setStatus('error'); + } + }; + + if (submitted) { + return ( +
+

Feedback Submitted

+

Thank you! Your feedback has been recorded.

+ +
+ ); + } + + return ( +
+

Submit Feedback

+ + {status === 'success' && ( +
+ Thank you! Your feedback has been submitted successfully. +
+ )} + {status === 'error' && ( +
+ Something went wrong. Please try again. +
+ )} + +
+
+ + setTitle(e.target.value)} + className="w-full p-2 border rounded-md bg-gray-50 text-gray-900" + /> +
+
+ + +
+ + {/* --- DYNAMIC BUG FIELDS (Shown only when 'bug' is chosen) --- */} + {type === 'bug' && ( + <> +
+ + +
+ +
+ +

If the URL is irrelevant for your bug report, just put down "N/A".

+ setBugUrl(e.target.value)} + className="w-full p-2 border rounded-md bg-gray-50 text-gray-900" + /> +
+ +
+ + +
+ + )} + {/* ------------------------------------------------------------- */} + + {/* Feature Request Fields (shown when type === 'feature') */} + {type === 'feature' && ( + <> +
+ + setFeatureContextUrl(e.target.value)} + className="w-full p-2 border rounded-md bg-gray-50 text-gray-900" + /> +
+
+ +