From d974fc5df142591c22ee220663da19c6d49f29b8 Mon Sep 17 00:00:00 2001 From: Superdancer16 Date: Thu, 20 Nov 2025 18:45:08 -0500 Subject: [PATCH 1/3] Update page.tsx Attempted adding of cross porting feature, PLEASE CHECK THIS before implementing it --- src/app/admin/subject/[slug]/page.tsx | 359 ++++++++++++++++++++++++-- 1 file changed, 342 insertions(+), 17 deletions(-) diff --git a/src/app/admin/subject/[slug]/page.tsx b/src/app/admin/subject/[slug]/page.tsx index 3fb60a7..d667ebd 100644 --- a/src/app/admin/subject/[slug]/page.tsx +++ b/src/app/admin/subject/[slug]/page.tsx @@ -7,9 +7,12 @@ import { useUser } from "@/components/hooks/UserContext"; import { db } from "@/lib/firebase"; import { collection, + type DocumentData, doc, getDoc, getDocs, + setDoc, + type QueryDocumentSnapshot, writeBatch, } from "firebase/firestore"; import { Button, buttonVariants } from "@/components/ui/button"; @@ -18,7 +21,7 @@ import { Blocker } from "@/app/admin/subject/navigation-block"; import apClassesData from "@/components/apClasses.json"; import { cn, formatSlug } from "@/lib/utils"; import short from "short-uuid"; -import type { Subject, Unit } from "@/types/firestore"; +import type { Subject, Unit, Chapter } from "@/types/firestore"; import UnitComponent from "./_components/unit"; const translator = short(short.constants.flickrBase58); @@ -75,6 +78,19 @@ export default function Page({ params }: { params: { slug: string } }) { // For adding a new unit const [newUnitTitle, setNewUnitTitle] = useState(""); + // Cross-port state (copy chapter/article from another subject) + const [crossportSource, setCrossportSource] = useState(""); + const [crossportUnits, setCrossportUnits] = useState([]); + const [crossportUnitId, setCrossportUnitId] = useState(""); + const [crossportChapters, setCrossportChapters] = useState([]); + const [crossportChapterId, setCrossportChapterId] = useState(""); + const [targetUnitId, setTargetUnitId] = useState(""); + const [newTargetUnitTitle, setNewTargetUnitTitle] = useState(""); + const [targetChapterTitle, setTargetChapterTitle] = useState(""); + const [isLoadingSource, setIsLoadingSource] = useState(false); + const [isCrossporting, setIsCrossporting] = useState(false); + const [crossportStatus, setCrossportStatus] = useState(null); + useEffect(() => { (async () => { try { @@ -129,14 +145,14 @@ export default function Page({ params }: { params: { slug: string } }) { ], tests: [], }; - setUnits((prev) => [...prev, newUnit]); + setUnits((prev: Unit[]) => [...prev, newUnit]); setNewUnitTitle(""); setUnsavedChanges(true); }; const handleDeleteUnit = (unitId: string) => { if (!confirm("Delete this unit?")) return; - setUnits((prev) => prev.filter((u) => u.id !== unitId)); + setUnits((prev: Unit[]) => prev.filter((u: Unit) => u.id !== unitId)); setUnsavedChanges(true); }; @@ -162,7 +178,9 @@ export default function Page({ params }: { params: { slug: string } }) { // Called by each whenever that unit updates const handleUnitChange = (unitId: string, updatedUnit: Unit) => { - setUnits((prev) => prev.map((u) => (u.id === unitId ? updatedUnit : u))); + setUnits((prev: Unit[]) => + prev.map((u: Unit) => (u.id === unitId ? updatedUnit : u)), + ); setUnsavedChanges(true); }; @@ -173,11 +191,11 @@ export default function Page({ params }: { params: { slug: string } }) { ****************************************************/ // Replaces save - const handleSave = async () => { + const handleSave = async (overrideUnits?: Unit[]) => { // Rebuild the Subject object from current state const subjectToSave: Subject = { title: subjectTitle, - units: units, + units: overrideUnits ?? units, }; try { @@ -208,11 +226,13 @@ export default function Page({ params }: { params: { slug: string } }) { const localChapterIds = new Set(unit.chapters.map((c) => c.id)); // c) For each chapter in Firestore, if it's NOT in our local data, delete it - existingChaptersSnap.forEach((chapterDoc) => { - if (!localChapterIds.has(chapterDoc.id)) { - batch.delete(chapterDoc.ref); - } - }); + existingChaptersSnap.forEach( + (chapterDoc: QueryDocumentSnapshot) => { + if (!localChapterIds.has(chapterDoc.id)) { + batch.delete(chapterDoc.ref); + } + }, + ); // d) Now, upsert all chapters from our local data for (const chapter of unit.chapters) { @@ -238,11 +258,13 @@ export default function Page({ params }: { params: { slug: string } }) { const localTestIds = new Set(unit.tests.map((t) => t.id)); // c) Delete any Firestore test that is no longer in our local data - existingTestsSnap.forEach((testDoc) => { - if (!localTestIds.has(testDoc.id)) { - batch.delete(testDoc.ref); - } - }); + existingTestsSnap.forEach( + (testDoc: QueryDocumentSnapshot) => { + if (!localTestIds.has(testDoc.id)) { + batch.delete(testDoc.ref); + } + }, + ); // d) Upsert all tests from our local data for (const test of unit.tests) { @@ -269,6 +291,194 @@ export default function Page({ params }: { params: { slug: string } }) { } }; + /**************************************************** + * CROSS-PORT ACTION + * Copy a chapter/article from another subject/unit. + ****************************************************/ + + // Load source subject units/chapters when the source changes + useEffect(() => { + if (!crossportSource) { + setCrossportUnits([]); + setCrossportUnitId(""); + setCrossportChapters([]); + setCrossportChapterId(""); + return; + } + + setIsLoadingSource(true); + setCrossportStatus(null); + + (async () => { + const sourceDocRef = doc(db, "subjects", crossportSource); + const sourceDocSnap = await getDoc(sourceDocRef); + + if (!sourceDocSnap.exists()) { + setCrossportUnits([]); + setCrossportUnitId(""); + setCrossportChapters([]); + setCrossportChapterId(""); + setCrossportStatus("Source subject not found in Firestore."); + return; + } + + const data = sourceDocSnap.data() as Subject; + setCrossportUnits(data.units ?? []); + setCrossportUnitId(""); + setCrossportChapters([]); + setCrossportChapterId(""); + })() + .catch((err) => + setCrossportStatus( + `Failed to load source subject: ${ + err instanceof Error ? err.message : String(err) + }`, + ), + ) + .finally(() => setIsLoadingSource(false)); + }, [crossportSource]); + + // When a source unit is chosen, refresh the list of source chapters + useEffect(() => { + const selectedUnit = crossportUnits.find( + (u: Unit) => u.id === crossportUnitId, + ); + setCrossportChapters(selectedUnit?.chapters ?? []); + setCrossportChapterId(""); + }, [crossportUnitId, crossportUnits]); + + // Auto-fill the target chapter title with the selected source chapter title + useEffect(() => { + const selectedChapter = crossportChapters.find( + (ch) => ch.id === crossportChapterId, + ); + if (selectedChapter) { + setTargetChapterTitle(selectedChapter.title ?? ""); + } + }, [crossportChapterId, crossportChapters]); + + const handleCrossport = async () => { + setCrossportStatus(null); + + if (!crossportSource || !crossportUnitId || !crossportChapterId) { + setCrossportStatus( + "Select a source subject, unit, and article to cross-port.", + ); + return; + } + + // Determine target unit for the incoming chapter + const usingNewUnit = targetUnitId === "__new__"; + const resolvedTargetUnitId = usingNewUnit + ? generateShortId() + : targetUnitId || units.at(0)?.id || ""; + + if (!resolvedTargetUnitId) { + setCrossportStatus("Choose a target unit to receive the article."); + return; + } + + if (usingNewUnit && !newTargetUnitTitle.trim()) { + setCrossportStatus("Enter a title for the new target unit."); + return; + } + + const selectedSourceChapter = + crossportChapters.find((ch) => ch.id === crossportChapterId) ?? null; + const chapterTitle = + targetChapterTitle.trim() || + selectedSourceChapter?.title || + "Imported Article"; + + setIsCrossporting(true); + try { + // Pull the source chapter content + const sourceChapterRef = doc( + db, + "subjects", + crossportSource, + "units", + crossportUnitId, + "chapters", + crossportChapterId, + ); + const sourceChapterSnap = await getDoc(sourceChapterRef); + if (!sourceChapterSnap.exists()) { + throw new Error("Source article not found at the chosen path."); + } + + // Clone current units so we can append the imported chapter + const clonedUnits: Unit[] = JSON.parse( + JSON.stringify(units ?? []), + ) as Unit[]; + let nextUnits = clonedUnits; + + if (usingNewUnit) { + nextUnits = [ + ...nextUnits, + { + id: resolvedTargetUnitId, + title: newTargetUnitTitle.trim(), + chapters: [], + tests: [], + }, + ]; + } + + const newChapterId = generateShortId(); + nextUnits = nextUnits.map((unit: Unit) => + unit.id === resolvedTargetUnitId + ? { + ...unit, + chapters: [ + ...(unit.chapters ?? []), + { id: newChapterId, title: chapterTitle }, + ], + } + : unit, + ); + + // Write the cloned content into the target location + const targetChapterRef = doc( + db, + "subjects", + params.slug, + "units", + resolvedTargetUnitId, + "chapters", + newChapterId, + ); + const sourceData = sourceChapterSnap.data(); + await setDoc(targetChapterRef, { + ...sourceData, + id: newChapterId, + title: chapterTitle, + copiedFrom: { + subject: crossportSource, + unitId: crossportUnitId, + chapterId: crossportChapterId, + at: new Date().toISOString(), + }, + }); + + // Sync UI and flag unsaved changes so the user can commit unit metadata + setUnits(nextUnits); + setTargetUnitId(resolvedTargetUnitId); + setUnsavedChanges(true); + setCrossportStatus( + `Copied "${chapterTitle}". Click "Save Changes" to persist the updated unit list.`, + ); + } catch (err) { + setCrossportStatus( + `Cross-port failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } finally { + setIsCrossporting(false); + } + }; + // Adds save // const handleSave = async () => { // // Rebuild the Subject object from current state @@ -393,7 +603,7 @@ export default function Page({ params }: { params: { slug: string } }) { setNewUnitTitle(e.target.value)} + onChange={(e: any) => setNewUnitTitle(e.target.value)} placeholder="New unit title" /> + + {/* Cross-port (import) an article from another subject */} +
+

Cross-Port Article

+

+ Copy a chapter/article from any subject (e.g., AP Calculus AB <-> AP Calculus BC) into this subject. +

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {targetUnitId === "__new__" && ( +
+ + setNewTargetUnitTitle(e.target.value)} + placeholder="e.g., Limits and Continuity" + /> +
+ )} + +
+ + setTargetChapterTitle(e.target.value)} + placeholder="Defaults to the source article title" + /> +
+
+ +
+ + {crossportStatus && ( +

{crossportStatus}

+ )} +
+
From 5dcbd37aed7c0a5ffb08557b4a1cf02c0c28b900 Mon Sep 17 00:00:00 2001 From: Superdancer16 Date: Thu, 20 Nov 2025 18:54:10 -0500 Subject: [PATCH 2/3] Update page.tsx Try number 2 on the cross port feature, as the first attempt failed the tests, I will do another try if it fails again --- src/app/admin/subject/[slug]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/admin/subject/[slug]/page.tsx b/src/app/admin/subject/[slug]/page.tsx index d667ebd..ee1cd49 100644 --- a/src/app/admin/subject/[slug]/page.tsx +++ b/src/app/admin/subject/[slug]/page.tsx @@ -619,7 +619,7 @@ export default function Page({ params }: { params: { slug: string } }) {

Cross-Port Article

- Copy a chapter/article from any subject (e.g., AP Calculus AB <-> AP Calculus BC) into this subject. + Copy a chapter/article from any subject (e.g., AP Calculus AB <-> AP Calculus BC) into this subject.

From 8504e83b1533dda1629aefa5d6343109f8345d2a Mon Sep 17 00:00:00 2001 From: Superdancer16 Date: Thu, 20 Nov 2025 18:59:27 -0500 Subject: [PATCH 3/3] Update page.tsx --- src/app/admin/subject/[slug]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/admin/subject/[slug]/page.tsx b/src/app/admin/subject/[slug]/page.tsx index ee1cd49..375fb86 100644 --- a/src/app/admin/subject/[slug]/page.tsx +++ b/src/app/admin/subject/[slug]/page.tsx @@ -569,7 +569,7 @@ export default function Page({ params }: { params: { slug: string } }) { "bg-blue-500 hover:bg-blue-600", unsavedChanges && "animate-pulse", )} - onClick={handleSave} + onClick={() => handleSave()} > Save Changes