From b674c46125541cf46eab5661c30403a6491388b6 Mon Sep 17 00:00:00 2001 From: Jose Cruz Date: Wed, 18 Mar 2026 00:37:48 -0400 Subject: [PATCH] Add landing page with liquid glass effects and move dashboard to /dashboard Replace the root route with a new marketing landing page featuring: - Hero section with 3D canvas knowledge graph, scramble text animation, and floating glass accent cards - Feature cards grid with spotlight hover effects and floating animation - Sticky scroll cinema "How It Works" section (3 steps) - Stats counter bar with intersection observer triggers - Onboarding modal with multi-step sign-up/sign-in flow - Liquid glass visual system (frosted translucency, backdrop blur, specular shimmer on hover) applied to all panel surfaces - Ambient gradient blobs behind sections for glass depth - Responsive design with Playfair Display, Inter, and JetBrains Mono fonts Move the original dashboard to /dashboard route and update Navbar routing to hide on the landing page and redirect accordingly. Made-with: Cursor --- frontend/package-lock.json | 42 +- frontend/package.json | 1 + frontend/src/app/dashboard/page.tsx | 1324 +++++++++++++++++++ frontend/src/app/globals.css | 236 ++++ frontend/src/app/layout.tsx | 22 +- frontend/src/app/page.tsx | 1843 +++++++++------------------ frontend/src/components/Navbar.tsx | 13 +- 7 files changed, 2221 insertions(+), 1260 deletions(-) create mode 100644 frontend/src/app/dashboard/page.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1ed8cf09..409715ce 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "d3": "^7.9.0", + "lucide-react": "^0.577.0", "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3", @@ -106,6 +107,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -669,6 +671,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -692,6 +695,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2603,7 +2607,6 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -2693,8 +2696,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3194,6 +3196,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3204,6 +3207,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3290,6 +3294,7 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -3808,6 +3813,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4199,6 +4205,7 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -4320,6 +4327,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5058,6 +5066,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -5391,8 +5400,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -5686,6 +5694,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5871,6 +5880,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7702,6 +7712,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -8688,6 +8699,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -9177,13 +9189,21 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", + "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -10649,7 +10669,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -10665,7 +10684,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -10678,8 +10696,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/prop-types": { "version": "15.8.1", @@ -10756,6 +10773,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10765,6 +10783,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11921,6 +11940,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12258,6 +12278,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12974,6 +12995,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index 4a9136d7..293df7e7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "d3": "^7.9.0", + "lucide-react": "^0.577.0", "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3", diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx new file mode 100644 index 00000000..1ba34c24 --- /dev/null +++ b/frontend/src/app/dashboard/page.tsx @@ -0,0 +1,1324 @@ +'use client'; + +import { useEffect, useState, useRef, useMemo, useCallback, Suspense } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import KnowledgeGraph from '@/components/KnowledgeGraph'; +import UploadZone from '@/components/UploadZone'; +import AssignmentTable from '@/components/AssignmentTable'; +import { GraphNode, GraphStats, Recommendation, Assignment } from '@/lib/types'; +import { getGraph, getRecommendations, getUpcomingAssignments, extractSyllabus, saveAssignments, getCourses, addCourse, deleteCourse, updateCourseColor } from '@/lib/api'; +import { getMasteryColor, getMasteryLabel, formatDueDate, formatRelativeTime, getCourseColor, PRESET_COURSE_COLORS } from '@/lib/graphUtils'; +import { useUser } from '@/context/UserContext'; +import Link from 'next/link'; + +const STATS_LABELS: Record = { + mastered: 'Mastered', + learning: 'Learning', + struggling: 'Struggling', + unexplored: 'Unexplored', +}; + +const GLASS: React.CSSProperties = { + background: '#ffffff', + border: '1px solid rgba(107, 114, 128, 0.15)', + borderRadius: '10px', +}; + +const UI_FONT = "var(--font-dm-sans), 'DM Sans', sans-serif"; + +const QUOTES = [ + '"The more that you read, the more things you will know." — Dr. Seuss', + '"Live as if you were to die tomorrow. Learn as if you were to live forever." — Gandhi', + '"The beautiful thing about learning is that no one can take it away from you." — B.B. King', + '"Education is not the filling of a pail, but the lighting of a fire." — W.B. Yeats', + '"An investment in knowledge pays the best interest." — Benjamin Franklin', + '"Tell me and I forget. Teach me and I remember. Involve me and I learn." — Benjamin Franklin', + '"The capacity to learn is a gift; the ability to learn is a skill; the willingness to learn is a choice." — Brian Herbert', + 'Fun fact: The human brain can store roughly 2.5 petabytes of information.', + 'Fun fact: Spaced repetition can boost long-term retention by up to 80%.', + 'Fun fact: Teaching others is one of the most effective ways to solidify your own knowledge.', + 'Fun fact: Your brain consolidates memories during sleep — rest is part of learning.', + 'Fun fact: Taking short breaks during study sessions can improve focus and retention.', + 'Fun fact: Handwriting notes activates more areas of the brain than typing them.', +]; + +function getTimeGreeting(): string { + const hour = new Date().getHours(); + if (hour >= 5 && hour < 12) return 'Good Morning'; + if (hour >= 12 && hour < 17) return 'Good Afternoon'; + return 'Good Evening'; +} + +function DashboardInner() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { userId, userName, userReady } = useUser(); + + // Suggested concept from Navbar "What should I learn next?" button + const suggestConcept = searchParams.get('suggest') ?? ''; + const containerRef = useRef(null); + const [graphDimensions, setGraphDimensions] = useState({ width: 600, height: 400 }); + + const [nodes, setNodes] = useState([]); + const [edges, setEdges] = useState([]); + const [stats, setStats] = useState(null); + const [recommendations, setRecommendations] = useState([]); + // All upcoming assignments — used by course panel and upcoming strip + const [allAssignments, setAllAssignments] = useState([]); + const [loading, setLoading] = useState(true); + + // Greeting animation + const [displayedGreeting, setDisplayedGreeting] = useState(''); + const [greetingDone, setGreetingDone] = useState(false); + const [cursorVisible, setCursorVisible] = useState(true); + const [quote, setQuote] = useState(''); + // Collapsed courses state (set of subject names that are collapsed) + const [collapsedCourses, setCollapsedCourses] = useState>(new Set()); + + const toggleCourse = (subject: string) => { + setCollapsedCourses(prev => { + const next = new Set(prev); + if (next.has(subject)) next.delete(subject); + else next.add(subject); + return next; + }); + }; + + // Courses panel state + const [showCourses, setShowCourses] = useState(false); + const [courseList, setCourseList] = useState<{ id: string; course_name: string; color: string | null; node_count: number }[]>([]); + const [courseColorMap, setCourseColorMap] = useState>({}); + const [newCourseName, setNewCourseName] = useState(''); + const [courseAdding, setCourseAdding] = useState(false); + const [courseDeleting, setCourseDeleting] = useState(null); + const [courseError, setCourseError] = useState(''); + // Inline color picker state + const [editingColorFor, setEditingColorFor] = useState(null); + const [colorHexInput, setColorHexInput] = useState(''); + + // Upload panel state + const [showUpload, setShowUpload] = useState(false); + const [uploadFilename, setUploadFilename] = useState(''); + const [uploadLoading, setUploadLoading] = useState(false); + const [uploadWarnings, setUploadWarnings] = useState([]); + const [extractedAssignments, setExtractedAssignments] = useState([]); + const [fileProcessed, setFileProcessed] = useState(false); + const [rawText, setRawText] = useState(''); + const [rawTextVisible, setRawTextVisible] = useState(false); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + + // Mon–Sun dates for the current week (computed once on mount) + const weekInfo = useMemo(() => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const todayISO = today.toISOString().split('T')[0]; + const dow = today.getDay(); // 0=Sun … 6=Sat + const daysFromMon = dow === 0 ? 6 : dow - 1; + const monday = new Date(today); + monday.setDate(today.getDate() - daysFromMon); + const LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + const dates = LABELS.map((label, i) => { + const d = new Date(monday); + d.setDate(monday.getDate() + i); + const iso = d.toISOString().split('T')[0]; + return { label, iso, isToday: iso === todayISO, isFuture: iso > todayISO }; + }); + return { todayISO, dates }; + }, []); + + // Which days this week had any study activity (derived from node last_studied_at) + const activeDaysThisWeek = useMemo(() => { + const set = new Set(); + const weekIsos = new Set(weekInfo.dates.map(d => d.iso)); + for (const n of nodes) { + if (n.last_studied_at) { + const iso = n.last_studied_at.split('T')[0]; + if (weekIsos.has(iso)) set.add(iso); + } + } + return set; + }, [nodes, weekInfo]); + + // Derive course list from graph nodes — one entry per unique subject, preserving discovery order + const courses = useMemo(() => { + const subjectMap = new Map(); + for (const n of nodes) { + if (!n.subject || n.is_subject_root || n.mastery_tier === 'subject_root') continue; + if (!subjectMap.has(n.subject)) subjectMap.set(n.subject, []); + subjectMap.get(n.subject)!.push(n); + } + return Array.from(subjectMap.entries()).map(([subject, courseNodes]) => { + const avgMastery = + courseNodes.length > 0 + ? courseNodes.reduce((s, n) => s + n.mastery_score, 0) / courseNodes.length + : 0; + return { subject, avgMastery, nodeCount: courseNodes.length }; + }); + }, [nodes]); + + // Filter out edges that cross subject boundaries so each course cluster stays separate. + // Subject-root edges (subject_root__*) are always kept; only same-subject concept edges are kept. + const filteredEdges = useMemo(() => { + const nodeSubjectMap = new Map(nodes.map(n => [n.id, n.subject])); + return edges.filter(e => { + const srcId = e.source as string; + const tgtId = e.target as string; + if (srcId.startsWith('subject_root__') || tgtId.startsWith('subject_root__')) return true; + const srcSubj = nodeSubjectMap.get(srcId); + const tgtSubj = nodeSubjectMap.get(tgtId); + return !srcSubj || !tgtSubj || srcSubj === tgtSubj; + }); + }, [nodes, edges]); + + // Node matching the Navbar's "learn next" suggestion + const suggestNode = useMemo( + () => (suggestConcept ? nodes.find(n => n.concept_name === suggestConcept) ?? null : null), + [nodes, suggestConcept] + ); + + useEffect(() => { + if (!userReady) return; // wait until localStorage user is resolved + async function load() { + try { + const [graphData, recData, assignData, courseData] = await Promise.all([ + getGraph(userId), + getRecommendations(userId), + getUpcomingAssignments(userId), + getCourses(userId), + ]); + setNodes(graphData.nodes); + setEdges(graphData.edges); + setStats(graphData.stats); + setRecommendations(recData.recommendations.slice(0, 3)); + setAllAssignments(assignData.assignments); + setCourseList(courseData.courses); + const colorMap: Record = {}; + courseData.courses.forEach(c => { if (c.color) colorMap[c.course_name] = c.color; }); + setCourseColorMap(colorMap); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + } + load(); + }, [userId, userReady]); + + // Pick a random quote on the client only (avoids SSR/client hydration mismatch) + useEffect(() => { + setQuote(QUOTES[Math.floor(Math.random() * QUOTES.length)]); + }, []); + + // Typing animation for greeting + useEffect(() => { + const firstName = userName.split(' ')[0]; + const greeting = `${getTimeGreeting()}, ${firstName}.`; + let i = 0; + setDisplayedGreeting(''); + setGreetingDone(false); + setCursorVisible(true); + const interval = setInterval(() => { + i++; + setDisplayedGreeting(greeting.slice(0, i)); + if (i >= greeting.length) { + clearInterval(interval); + setTimeout(() => setGreetingDone(true), 300); + } + }, 55); + return () => clearInterval(interval); + }, [userName]); + + // Blinking cursor while typing + useEffect(() => { + if (greetingDone) { + setCursorVisible(false); + return; + } + const blink = setInterval(() => setCursorVisible(v => !v), 530); + return () => clearInterval(blink); + }, [greetingDone]); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const obs = new ResizeObserver(entries => { + const entry = entries[0]; + if (entry) setGraphDimensions({ width: entry.contentRect.width, height: entry.contentRect.height }); + }); + obs.observe(el); + return () => obs.disconnect(); + }, []); + + const handleNodeClick = useCallback((node: GraphNode) => { + router.push(`/learn?topic=${encodeURIComponent(node.concept_name)}`); + }, [router]); + + const handleFile = async (file: File) => { + setUploadFilename(file.name); + setUploadLoading(true); + setUploadWarnings([]); + setExtractedAssignments([]); + setFileProcessed(false); + setRawText(''); + setRawTextVisible(false); + try { + const form = new FormData(); + form.append('file', file); + const res = await extractSyllabus(form); + const mapped: Assignment[] = (res.assignments ?? []).map((a: any, i: number) => ({ + id: `extracted_${i}_${Date.now()}`, + title: a.title ?? '', + course_name: a.course_name ?? '', + due_date: a.due_date ?? '', + assignment_type: a.assignment_type ?? 'other', + notes: a.notes ?? null, + google_event_id: null, + })); + setExtractedAssignments(mapped); + setUploadWarnings(res.warnings ?? []); + setRawText(res.raw_text ?? ''); + setFileProcessed(true); + } catch (e: any) { + setUploadWarnings([e.message || 'Extraction failed']); + } finally { + setUploadLoading(false); + } + }; + + const handleSave = async () => { + setSaving(true); + try { + await saveAssignments(userId, extractedAssignments); + setAllAssignments(prev => { + const ids = new Set(prev.map(a => a.id)); + return [...prev, ...extractedAssignments.filter(a => !ids.has(a.id))]; + }); + setSaved(true); + setTimeout(() => { + setSaved(false); + closeUpload(); + }, 1500); + } catch (e) { + console.error(e); + } finally { + setSaving(false); + } + }; + + const closeUpload = () => { + setShowUpload(false); + setUploadFilename(''); + setUploadLoading(false); + setUploadWarnings([]); + setExtractedAssignments([]); + setFileProcessed(false); + setRawText(''); + setRawTextVisible(false); + }; + + const handleAddCourse = async () => { + const name = newCourseName.trim(); + if (!name) return; + setCourseError(''); + setCourseAdding(true); + try { + // Pick a preset color not already used by any existing course + const usedColors = new Set(Object.values(courseColorMap)); + const pickedColor = PRESET_COURSE_COLORS.find(c => !usedColors.has(c)) ?? PRESET_COURSE_COLORS[0]; + const res = await addCourse(userId, name, pickedColor); + if (res.already_existed) { + setCourseError(`"${name}" is already in your course list.`); + } else { + setNewCourseName(''); + const updated = await getCourses(userId); + setCourseList(updated.courses); + const colorMap: Record = {}; + updated.courses.forEach(c => { if (c.color) colorMap[c.course_name] = c.color; }); + setCourseColorMap(colorMap); + // Refresh graph so the new subject-root node appears immediately + const graphData = await getGraph(userId); + setNodes(graphData.nodes); + setEdges(graphData.edges); + } + } catch (e: any) { + setCourseError(e.message || 'Failed to add course.'); + } finally { + setCourseAdding(false); + } + }; + + const handleColorChange = async (courseName: string, newHex: string) => { + if (!/^#[0-9a-fA-F]{6}$/.test(newHex)) return; + try { + await updateCourseColor(userId, courseName, newHex); + setCourseList(prev => prev.map(c => c.course_name === courseName ? { ...c, color: newHex } : c)); + setCourseColorMap(prev => ({ ...prev, [courseName]: newHex })); + setEditingColorFor(null); + } catch (e) { + console.error(e); + } + }; + + const handleDeleteCourse = async (courseName: string) => { + setCourseDeleting(courseName); + try { + await deleteCourse(userId, courseName); + setCourseList(prev => prev.filter(c => c.course_name !== courseName)); + // Refresh graph so the removed subject-root node disappears + const graphData = await getGraph(userId); + setNodes(graphData.nodes); + setEdges(graphData.edges); + } catch (e) { + console.error(e); + } finally { + setCourseDeleting(null); + } + }; + + return ( + <> +
+ + {/* ── Left panel: Course list ─────────────────────────────────────── */} +
+

+ Courses +

+ + {loading ? ( +
Loading…
+ ) : courseList.length === 0 ? ( +
No courses yet
+ ) : ( + courseList.map(course => { + const subject = course.course_name; + const c = getCourseColor(subject, course.color); + const conceptNodes = nodes.filter(n => n.subject === subject && !n.is_subject_root && n.mastery_tier !== 'subject_root'); + const avgMastery = conceptNodes.length > 0 + ? conceptNodes.reduce((s, n) => s + n.mastery_score, 0) / conceptNodes.length + : 0; + const pct = Math.round(avgMastery * 100); + + // 5 soonest upcoming assignments for this course (case-insensitive match) + const courseAssignments = allAssignments + .filter(a => a.course_name?.toLowerCase() === subject.toLowerCase() && a.due_date) + .sort((a, b) => a.due_date.localeCompare(b.due_date)) + .slice(0, 5); + + const isCollapsed = collapsedCourses.has(subject); + + return ( +
+ {/* Course name row — clickable to collapse */} + + + {/* Progress bar */} +
+
+
+ + {pct}% mastery + + + {/* Upcoming assignments — always rendered, collapsed via maxHeight */} +
+
+ {courseAssignments.length === 0 ? ( + No upcoming assignments + ) : courseAssignments.map(a => ( +
+ {/* Assignment title in course color */} +
+ {a.title} +
+ {/* Category / type + due date */} +
+ + {a.assignment_type} + + {formatDueDate(a.due_date)} +
+
+ ))} +
+
+
+ ); + }) + )} +
+ + {/* ── Center: Greeting + Graph + Upcoming ────────────────────────── */} +
+ + {/* Header: Typed Greeting + Quote + Action Buttons */} +
+

+ {displayedGreeting} + + | + +

+ +

+ {quote} +

+ +
+ + Start Learning + + + +
+
+ + {/* Knowledge Graph */} +
+ {loading ? ( +
+ Loading graph… +
+ ) : ( + + )} + + {/* AI "learn next" suggestion popup */} + {suggestConcept && suggestNode && ( +
+
+ +
+

+ AI Recommendation +

+

+ {suggestConcept} +

+

+ Based on your knowledge graph, this concept will have the highest impact on your mastery. +

+
+
+
+ + +
+
+ )} +
+ + {/* Upcoming assignments strip */} +
+
+

+ Upcoming +

+ + View Calendar + +
+ {allAssignments.length === 0 ? ( +

No upcoming assignments

+ ) : ( +
+ {allAssignments.slice(0, 4).map(a => { + const c = getCourseColor(a.course_name, courseColorMap[a.course_name]); + return ( +
+ + {formatDueDate(a.due_date)} + + + {a.course_name} + + {a.title} +
+ ); + })} +
+ )} +
+
+ + {/* ── Right: Sidebar ──────────────────────────────────────────────── */} +
+ + {/* User header + streak */} +
+

{userName}

+ + {/* Streak count */} +
+ 🔥 + + {stats?.streak ?? 0} + + day streak +
+ + {/* 7-day week strip */} +
+ {weekInfo.dates.map(({ label, iso, isToday, isFuture }) => { + const isActive = activeDaysThisWeek.has(iso); + return ( +
+ {/* Day label */} + + {label} + + {/* Fire or empty ring */} + {isActive ? ( + 🔥 + ) : ( +
+ )} +
+ ); + })} +
+
+ + {/* Stats */} + {stats && ( +
+

+ Knowledge +

+ {(['mastered', 'learning', 'struggling', 'unexplored'] as const).map(tier => ( +
+
+ + {stats[tier]} {STATS_LABELS[tier]} + +
+ ))} +
+ )} + + {/* Recommendations */} + {recommendations.length > 0 && ( +
+

+ Learn Next +

+ {recommendations.map(rec => { + const node = nodes.find(n => n.concept_name === rec.concept_name); + return ( + + {rec.concept_name} + + {node ? getMasteryLabel(node.mastery_score) : '0%'} + + + ); + })} +
+ )} + + {/* Actions */} +
+ + Quick Quiz + + + Study Room + +
+ + {/* Recent activity */} + {nodes.length > 0 && ( +
+

+ Recent Activity +

+ {nodes + .filter(n => n.last_studied_at) + .sort((a, b) => (b.last_studied_at ?? '').localeCompare(a.last_studied_at ?? '')) + .slice(0, 4) + .map(n => ( +
+ + {n.concept_name} — {getMasteryLabel(n.mastery_score)} + + + {formatRelativeTime(n.last_studied_at)} + +
+ ))} +
+ )} +
+
+ + {/* ── Upload Assignments Modal ────────────────────────────────────────── */} + {showUpload && ( +
{ if (e.target === e.currentTarget) closeUpload(); }} + > +
+ + +

+ Upload Assignments +

+

+ Upload a syllabus PDF to automatically extract assignment deadlines and names. Edit before saving. +

+ +
+

+ Syllabus File +

+ + {uploadWarnings.map((w, i) => ( +

{w}

+ ))} +
+ + {fileProcessed && ( +
+
+

+ {extractedAssignments.length > 0 + ? `Detected ${extractedAssignments.length} assignment${extractedAssignments.length !== 1 ? 's' : ''} — edit before saving` + : 'No assignments detected — add rows manually'} +

+ +
+ + + + {rawText && ( +
+ + {rawTextVisible && ( +
+                        {rawText}
+                      
+ )} +
+ )} +
+ )} +
+
+ )} + {/* ── Courses Modal ───────────────────────────────────────────────────── */} + {showCourses && ( +
{ if (e.target === e.currentTarget) { setShowCourses(false); setCourseError(''); setNewCourseName(''); setEditingColorFor(null); } }} + > +
+ {/* Close */} + + +

+ My Courses +

+

+ Courses appear as large hub nodes on your knowledge tree. Deleting a course removes all its concept nodes. +

+ + {/* Course list */} +
+ {courseList.length === 0 ? ( +

No courses added yet.

+ ) : ( + courseList.map(c => { + const color = getCourseColor(c.course_name, c.color); + const isDeleting = courseDeleting === c.course_name; + const isEditingColor = editingColorFor === c.course_name; + return ( +
+ {/* Main row */} +
+
+ {/* Color swatch — click to open picker */} +
+ +
+ + {/* Inline color picker (animated via maxHeight) */} +
+
+ {/* Preset swatches */} +

+ Preset colours +

+
+ {PRESET_COURSE_COLORS.map(hex => ( +
+ {/* Hex input */} +
+
+ setColorHexInput(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleColorChange(c.course_name, colorHexInput); }} + placeholder="#2563eb" + style={{ + flex: 1, padding: '4px 8px', border: '1px solid rgba(107,114,128,0.25)', + borderRadius: '4px', fontSize: '12px', fontFamily: 'monospace', + outline: 'none', color: '#111827', + }} + /> + +
+
+
+
+ ); + }) + )} +
+ + {/* Divider */} +
+ + {/* Add new course */} +

+ Add a Course +

+
+ { setNewCourseName(e.target.value); setCourseError(''); }} + onKeyDown={e => { if (e.key === 'Enter') handleAddCourse(); }} + placeholder="e.g. Calculus II, World History…" + style={{ + flex: 1, + padding: '8px 12px', + border: '1px solid rgba(107,114,128,0.25)', + borderRadius: '6px', + fontSize: '13px', + outline: 'none', + fontFamily: 'inherit', + color: '#111827', + }} + /> + +
+ {courseError && ( +

{courseError}

+ )} +
+
+ )} + + ); +} + +export default function Dashboard() { + return ( + + + + ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index ab16cda9..aea67aaf 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -383,3 +383,239 @@ input, select, textarea { color-scheme: light; } color: #1a5c2a !important; } +/* ════════════════════════════════════════════════════════════════════ + LANDING PAGE STYLES + ════════════════════════════════════════════════════════════════════ */ + +.font-playfair { font-family: var(--font-playfair), 'Playfair Display', serif; } +.font-inter { font-family: var(--font-inter), 'Inter', sans-serif; } +.font-jetbrains { font-family: var(--font-jetbrains), 'JetBrains Mono', monospace; } + +/* Prevent horizontal overflow without breaking position: sticky */ +.landing-page { clip-path: inset(0); } + +/* ── Ambient glow ──────────────────────────────────────────────── */ +.landing-ambient-glow { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background: radial-gradient(ellipse at 50% 20%, rgba(255,255,255,0.7) 0%, transparent 60%); + transition: background 0.8s ease; +} + +/* ── Gradient shift ────────────────────────────────────────────── */ +@keyframes landing-gradient-shift { + 0% { background-position: 0% center; } + 100% { background-position: 100% center; } +} +.landing-animate-gradient { + background-size: 200% auto; + animation: landing-gradient-shift 5s ease-in-out infinite alternate; +} + +/* ── Slow pulse ────────────────────────────────────────────────── */ +@keyframes landing-pulse-slow { + 0%, 100% { transform: scale(0.98); } + 50% { transform: scale(1.02); } +} +.landing-animate-pulse-slow { animation: landing-pulse-slow 4s ease-in-out infinite; } + +/* ── Float indicator ───────────────────────────────────────────── */ +@keyframes landing-float-indicator { + 0%, 100% { transform: translateY(0) translateX(-50%); } + 50% { transform: translateY(6px) translateX(-50%); } +} +.landing-animate-float-indicator { animation: landing-float-indicator 2.5s ease-in-out infinite; } + +/* ── Button shimmer ────────────────────────────────────────────── */ +.landing-btn-shimmer { position: relative; overflow: hidden; } +.landing-btn-shimmer::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(to right, transparent, rgba(255,255,255,0.3), transparent); + transform: translateX(-200%); + transition: transform 0.7s ease; + border-radius: 9999px; + pointer-events: none; +} +.landing-btn-shimmer:hover::before { transform: translateX(200%); } + +/* ── Spotlight card (mouse-follow glow) ────────────────────────── */ +.landing-spotlight-card { + position: relative; + overflow: hidden; +} +.landing-spotlight-card::before { + content: ""; + position: absolute; + inset: 0; + border-radius: 1rem; + background: radial-gradient(250px circle at var(--mouse-x, 0) var(--mouse-y, 0), rgba(27,108,66,0.04) 0%, transparent 70%); + opacity: 0; + transition: opacity 0.5s ease; + pointer-events: none; + z-index: 0; +} +.landing-spotlight-card:hover::before { opacity: 1; } + +/* ── Feature card icon hover ───────────────────────────────────── */ +.landing-icon-container { + transition: transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} +.landing-spotlight-card:hover .landing-icon-container { + transform: scale(1.1) rotate(5deg); +} + +/* ── Feature card float ────────────────────────────────────────── */ +@keyframes landing-card-float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-8px); } +} +.landing-card-float-1 { animation: landing-card-float 5.0s ease-in-out infinite; } +.landing-card-float-2 { animation: landing-card-float 5.5s ease-in-out infinite 0.4s; } +.landing-card-float-3 { animation: landing-card-float 4.8s ease-in-out infinite 0.8s; } +.landing-card-float-4 { animation: landing-card-float 5.3s ease-in-out infinite 1.2s; } +.landing-card-float-5 { animation: landing-card-float 5.1s ease-in-out infinite 0.6s; } +.landing-card-float-6 { animation: landing-card-float 4.6s ease-in-out infinite 1.0s; } + +.landing-card-float-1:hover, +.landing-card-float-2:hover, +.landing-card-float-3:hover, +.landing-card-float-4:hover, +.landing-card-float-5:hover, +.landing-card-float-6:hover { + animation-play-state: paused; +} + +/* ── Landing scrollbar ─────────────────────────────────────────── */ +.landing-page ::-webkit-scrollbar { width: 6px; } +.landing-page ::-webkit-scrollbar-track { background: #E9EFED; } +.landing-page ::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 3px; } +.landing-page ::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.25); } + +/* ════════════════════════════════════════════════════════════════════ + LIQUID GLASS SYSTEM + ════════════════════════════════════════════════════════════════════ */ + +/* Base glass panel */ +.liquid-glass { + background: rgba(255, 255, 255, 0.42); + backdrop-filter: blur(24px) saturate(1.6); + -webkit-backdrop-filter: blur(24px) saturate(1.6); + border: 1px solid rgba(255, 255, 255, 0.55); + box-shadow: + inset 0 1px 0 0 rgba(255, 255, 255, 0.7), + inset 0 -1px 0 0 rgba(0, 0, 0, 0.04), + 0 4px 24px -2px rgba(0, 0, 0, 0.06), + 0 1px 3px rgba(0, 0, 0, 0.04); + position: relative; + overflow: hidden; + isolation: isolate; +} + +/* Specular shimmer on hover */ +.liquid-glass::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background: linear-gradient( + 105deg, + transparent 30%, + rgba(255, 255, 255, 0.25) 45%, + rgba(255, 255, 255, 0.08) 55%, + transparent 70% + ); + opacity: 0; + transform: translateX(-100%); + transition: opacity 0.4s ease, transform 0.7s ease; + pointer-events: none; + z-index: 2; +} +.liquid-glass:hover::after { + opacity: 1; + transform: translateX(100%); +} + +/* Stronger glass for modals / overlays */ +.liquid-glass-strong { + background: rgba(255, 255, 255, 0.72); + backdrop-filter: blur(40px) saturate(1.8); + -webkit-backdrop-filter: blur(40px) saturate(1.8); + border: 1px solid rgba(255, 255, 255, 0.6); + box-shadow: + inset 0 1px 0 0 rgba(255, 255, 255, 0.8), + inset 0 -1px 0 0 rgba(0, 0, 0, 0.03), + 0 12px 48px -4px rgba(0, 0, 0, 0.12), + 0 2px 6px rgba(0, 0, 0, 0.04); +} + +/* Subtle glass for pills / small buttons */ +.liquid-glass-subtle { + background: rgba(255, 255, 255, 0.50); + backdrop-filter: blur(16px) saturate(1.4); + -webkit-backdrop-filter: blur(16px) saturate(1.4); + border: 1px solid rgba(255, 255, 255, 0.5); + box-shadow: + inset 0 1px 0 0 rgba(255, 255, 255, 0.6), + 0 2px 8px rgba(0, 0, 0, 0.04); +} + +/* ── Ambient gradient blobs ────────────────────────────────────── */ +@keyframes landing-blob-drift { + 0% { transform: translate(0, 0) scale(1); } + 33% { transform: translate(40px, -30px) scale(1.08); } + 66% { transform: translate(-20px, 20px) scale(0.95); } + 100% { transform: translate(0, 0) scale(1); } +} + +.landing-ambient-blobs { + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: none; + z-index: 0; +} + +.landing-ambient-blobs::before, +.landing-ambient-blobs::after { + content: ''; + position: absolute; + border-radius: 50%; + filter: blur(80px); + animation: landing-blob-drift 18s ease-in-out infinite; +} +.landing-ambient-blobs::before { + width: 500px; + height: 500px; + top: -10%; + left: -5%; + background: radial-gradient(circle, rgba(138,99,210,0.10) 0%, rgba(59,130,246,0.06) 50%, transparent 70%); + animation-duration: 20s; +} +.landing-ambient-blobs::after { + width: 450px; + height: 450px; + bottom: -10%; + right: -5%; + background: radial-gradient(circle, rgba(20,184,166,0.08) 0%, rgba(217,119,6,0.06) 50%, transparent 70%); + animation-duration: 16s; + animation-delay: -6s; +} + +.landing-blob-extra { + position: absolute; + width: 400px; + height: 400px; + top: 40%; + left: 50%; + transform: translateX(-50%); + border-radius: 50%; + background: radial-gradient(circle, rgba(59,130,246,0.07) 0%, rgba(138,99,210,0.05) 50%, transparent 70%); + filter: blur(80px); + pointer-events: none; + animation: landing-blob-drift 22s ease-in-out infinite -4s; +} + diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 002f5855..ec3e1c44 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata } from 'next'; -import { Spectral, DM_Sans } from 'next/font/google'; +import { Spectral, DM_Sans, Inter, Playfair_Display, JetBrains_Mono } from 'next/font/google'; import './globals.css'; import { UserProvider } from '@/context/UserContext'; import Navbar from '@/components/Navbar'; @@ -19,6 +19,24 @@ const dmSans = DM_Sans({ display: 'swap', }); +const inter = Inter({ + subsets: ['latin'], + variable: '--font-inter', + display: 'swap', +}); + +const playfairDisplay = Playfair_Display({ + subsets: ['latin'], + variable: '--font-playfair', + display: 'swap', +}); + +const jetbrainsMono = JetBrains_Mono({ + subsets: ['latin'], + variable: '--font-jetbrains', + display: 'swap', +}); + export const metadata: Metadata = { title: 'Sapling', description: 'Learn through conversation. Watch your knowledge grow.', @@ -30,7 +48,7 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 1ba34c24..a00a2952 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,1324 +1,681 @@ 'use client'; -import { useEffect, useState, useRef, useMemo, useCallback, Suspense } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import KnowledgeGraph from '@/components/KnowledgeGraph'; -import UploadZone from '@/components/UploadZone'; -import AssignmentTable from '@/components/AssignmentTable'; -import { GraphNode, GraphStats, Recommendation, Assignment } from '@/lib/types'; -import { getGraph, getRecommendations, getUpcomingAssignments, extractSyllabus, saveAssignments, getCourses, addCourse, deleteCourse, updateCourseColor } from '@/lib/api'; -import { getMasteryColor, getMasteryLabel, formatDueDate, formatRelativeTime, getCourseColor, PRESET_COURSE_COLORS } from '@/lib/graphUtils'; +import { useEffect, useState, useRef, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; import { useUser } from '@/context/UserContext'; -import Link from 'next/link'; +import { + Network, Sparkles, FilePlus2, Brain, CalendarClock, Users, + PenSquare, X, Search, ChevronDown, XCircle, +} from 'lucide-react'; -const STATS_LABELS: Record = { - mastered: 'Mastered', - learning: 'Learning', - struggling: 'Struggling', - unexplored: 'Unexplored', -}; +const SCRAMBLE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!<>-_\\/[]{}=+*^?#_"; -const GLASS: React.CSSProperties = { - background: '#ffffff', - border: '1px solid rgba(107, 114, 128, 0.15)', - borderRadius: '10px', -}; - -const UI_FONT = "var(--font-dm-sans), 'DM Sans', sans-serif"; - -const QUOTES = [ - '"The more that you read, the more things you will know." — Dr. Seuss', - '"Live as if you were to die tomorrow. Learn as if you were to live forever." — Gandhi', - '"The beautiful thing about learning is that no one can take it away from you." — B.B. King', - '"Education is not the filling of a pail, but the lighting of a fire." — W.B. Yeats', - '"An investment in knowledge pays the best interest." — Benjamin Franklin', - '"Tell me and I forget. Teach me and I remember. Involve me and I learn." — Benjamin Franklin', - '"The capacity to learn is a gift; the ability to learn is a skill; the willingness to learn is a choice." — Brian Herbert', - 'Fun fact: The human brain can store roughly 2.5 petabytes of information.', - 'Fun fact: Spaced repetition can boost long-term retention by up to 80%.', - 'Fun fact: Teaching others is one of the most effective ways to solidify your own knowledge.', - 'Fun fact: Your brain consolidates memories during sleep — rest is part of learning.', - 'Fun fact: Taking short breaks during study sessions can improve focus and retention.', - 'Fun fact: Handwriting notes activates more areas of the brain than typing them.', -]; - -function getTimeGreeting(): string { - const hour = new Date().getHours(); - if (hour >= 5 && hour < 12) return 'Good Morning'; - if (hour >= 12 && hour < 17) return 'Good Afternoon'; - return 'Good Evening'; -} - -function DashboardInner() { +export default function LandingPage() { const router = useRouter(); - const searchParams = useSearchParams(); - const { userId, userName, userReady } = useUser(); - - // Suggested concept from Navbar "What should I learn next?" button - const suggestConcept = searchParams.get('suggest') ?? ''; - const containerRef = useRef(null); - const [graphDimensions, setGraphDimensions] = useState({ width: 600, height: 400 }); + const { userReady, isAuthenticated } = useUser(); + + const [heroMounted, setHeroMounted] = useState(false); + const [heroText1, setHeroText1] = useState(''); + const [heroText2, setHeroText2] = useState(''); + const [modalOpen, setModalOpen] = useState(false); + const [modalMode, setModalMode] = useState<'signin' | 'signup'>('signup'); + const [modalStep, setModalStep] = useState(1); + const [classChips, setClassChips] = useState([]); + const [classInput, setClassInput] = useState(''); + + const canvasRef = useRef(null); + const heroContentRef = useRef(null); + const floatingCardsRef = useRef(null); + const stickyRef = useRef(null); + const step1Ref = useRef(null); + const step2Ref = useRef(null); + const step3Ref = useRef(null); + const stepNumRef = useRef(null); + const navRef = useRef(null); + const parallaxYRef = useRef(0); + const mouseRef = useRef({ x: 0, y: 0 }); - const [nodes, setNodes] = useState([]); - const [edges, setEdges] = useState([]); - const [stats, setStats] = useState(null); - const [recommendations, setRecommendations] = useState([]); - // All upcoming assignments — used by course panel and upcoming strip - const [allAssignments, setAllAssignments] = useState([]); - const [loading, setLoading] = useState(true); - - // Greeting animation - const [displayedGreeting, setDisplayedGreeting] = useState(''); - const [greetingDone, setGreetingDone] = useState(false); - const [cursorVisible, setCursorVisible] = useState(true); - const [quote, setQuote] = useState(''); - // Collapsed courses state (set of subject names that are collapsed) - const [collapsedCourses, setCollapsedCourses] = useState>(new Set()); - - const toggleCourse = (subject: string) => { - setCollapsedCourses(prev => { - const next = new Set(prev); - if (next.has(subject)) next.delete(subject); - else next.add(subject); - return next; - }); - }; - - // Courses panel state - const [showCourses, setShowCourses] = useState(false); - const [courseList, setCourseList] = useState<{ id: string; course_name: string; color: string | null; node_count: number }[]>([]); - const [courseColorMap, setCourseColorMap] = useState>({}); - const [newCourseName, setNewCourseName] = useState(''); - const [courseAdding, setCourseAdding] = useState(false); - const [courseDeleting, setCourseDeleting] = useState(null); - const [courseError, setCourseError] = useState(''); - // Inline color picker state - const [editingColorFor, setEditingColorFor] = useState(null); - const [colorHexInput, setColorHexInput] = useState(''); - - // Upload panel state - const [showUpload, setShowUpload] = useState(false); - const [uploadFilename, setUploadFilename] = useState(''); - const [uploadLoading, setUploadLoading] = useState(false); - const [uploadWarnings, setUploadWarnings] = useState([]); - const [extractedAssignments, setExtractedAssignments] = useState([]); - const [fileProcessed, setFileProcessed] = useState(false); - const [rawText, setRawText] = useState(''); - const [rawTextVisible, setRawTextVisible] = useState(false); - const [saving, setSaving] = useState(false); - const [saved, setSaved] = useState(false); + useEffect(() => { + if (userReady && isAuthenticated) { + router.replace('/dashboard'); + } + }, [userReady, isAuthenticated, router]); - // Mon–Sun dates for the current week (computed once on mount) - const weekInfo = useMemo(() => { - const today = new Date(); - today.setHours(0, 0, 0, 0); - const todayISO = today.toISOString().split('T')[0]; - const dow = today.getDay(); // 0=Sun … 6=Sat - const daysFromMon = dow === 0 ? 6 : dow - 1; - const monday = new Date(today); - monday.setDate(today.getDate() - daysFromMon); - const LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; - const dates = LABELS.map((label, i) => { - const d = new Date(monday); - d.setDate(monday.getDate() + i); - const iso = d.toISOString().split('T')[0]; - return { label, iso, isToday: iso === todayISO, isFuture: iso > todayISO }; - }); - return { todayISO, dates }; + const scrambleText = useCallback((setter: (v: string) => void, final: string, duration: number) => { + const start = Date.now(); + const interval = setInterval(() => { + const progress = (Date.now() - start) / duration; + if (progress >= 1) { setter(final); clearInterval(interval); return; } + setter(final.split('').map((ch, idx) => { + if (ch === ' ' || ch === '\n') return ch; + if (progress > idx / final.length) return ch; + return SCRAMBLE_CHARS[Math.floor(Math.random() * SCRAMBLE_CHARS.length)]; + }).join('')); + }, 30); + return () => clearInterval(interval); }, []); - // Which days this week had any study activity (derived from node last_studied_at) - const activeDaysThisWeek = useMemo(() => { - const set = new Set(); - const weekIsos = new Set(weekInfo.dates.map(d => d.iso)); - for (const n of nodes) { - if (n.last_studied_at) { - const iso = n.last_studied_at.split('T')[0]; - if (weekIsos.has(iso)) set.add(iso); - } - } - return set; - }, [nodes, weekInfo]); - - // Derive course list from graph nodes — one entry per unique subject, preserving discovery order - const courses = useMemo(() => { - const subjectMap = new Map(); - for (const n of nodes) { - if (!n.subject || n.is_subject_root || n.mastery_tier === 'subject_root') continue; - if (!subjectMap.has(n.subject)) subjectMap.set(n.subject, []); - subjectMap.get(n.subject)!.push(n); + useEffect(() => { + const timeout = setTimeout(() => { + setHeroMounted(true); + scrambleText(setHeroText1, 'Sapling', 1000); + setTimeout(() => scrambleText(setHeroText2, 'Grow Your Knowledge', 1200), 200); + }, 300); + return () => clearTimeout(timeout); + }, [scrambleText]); + + // 3D Canvas graph + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d', { alpha: false }); + if (!ctx) return; + + let width = 0, height = 0; + let rotAngle = 0; + let animId: number; + + const palette = [ + { c: '#8A63D2', w: 0.25 }, { c: '#3B82F6', w: 0.25 }, + { c: '#D97706', w: 0.20 }, { c: '#14B8A6', w: 0.15 }, { c: '#D1D5DB', w: 0.15 }, + ]; + function randColor() { + let r = Math.random(), s = 0; + for (const p of palette) { s += p.w; if (r <= s) return p.c; } + return palette[0].c; } - return Array.from(subjectMap.entries()).map(([subject, courseNodes]) => { - const avgMastery = - courseNodes.length > 0 - ? courseNodes.reduce((s, n) => s + n.mastery_score, 0) / courseNodes.length - : 0; - return { subject, avgMastery, nodeCount: courseNodes.length }; - }); - }, [nodes]); - // Filter out edges that cross subject boundaries so each course cluster stays separate. - // Subject-root edges (subject_root__*) are always kept; only same-subject concept edges are kept. - const filteredEdges = useMemo(() => { - const nodeSubjectMap = new Map(nodes.map(n => [n.id, n.subject])); - return edges.filter(e => { - const srcId = e.source as string; - const tgtId = e.target as string; - if (srcId.startsWith('subject_root__') || tgtId.startsWith('subject_root__')) return true; - const srcSubj = nodeSubjectMap.get(srcId); - const tgtSubj = nodeSubjectMap.get(tgtId); - return !srcSubj || !tgtSubj || srcSubj === tgtSubj; + const clusters = [ + { x: 200, y: -100, z: 100 }, { x: -250, y: 150, z: -50 }, + { x: 0, y: -200, z: -200 }, { x: 300, y: 200, z: 150 }, + { x: -150, y: -250, z: 200 }, { x: 100, y: 300, z: -100 }, + ]; + const spread = 180; + const nodes = Array.from({ length: 130 }, () => { + const cl = clusters[Math.floor(Math.random() * clusters.length)]; + return { + ox: cl.x + (Math.random() - 0.5) * spread, + oy: cl.y + (Math.random() - 0.5) * spread, + oz: cl.z + (Math.random() - 0.5) * spread, + color: randColor(), + radius: 1 + Math.random() * 4, + seed: Math.random() * 100, + }; }); - }, [nodes, edges]); - - // Node matching the Navbar's "learn next" suggestion - const suggestNode = useMemo( - () => (suggestConcept ? nodes.find(n => n.concept_name === suggestConcept) ?? null : null), - [nodes, suggestConcept] - ); - useEffect(() => { - if (!userReady) return; // wait until localStorage user is resolved - async function load() { - try { - const [graphData, recData, assignData, courseData] = await Promise.all([ - getGraph(userId), - getRecommendations(userId), - getUpcomingAssignments(userId), - getCourses(userId), - ]); - setNodes(graphData.nodes); - setEdges(graphData.edges); - setStats(graphData.stats); - setRecommendations(recData.recommendations.slice(0, 3)); - setAllAssignments(assignData.assignments); - setCourseList(courseData.courses); - const colorMap: Record = {}; - courseData.courses.forEach(c => { if (c.color) colorMap[c.course_name] = c.color; }); - setCourseColorMap(colorMap); - } catch (e) { - console.error(e); - } finally { - setLoading(false); + function resize() { + width = window.innerWidth; + height = window.innerHeight; + canvas!.width = width * devicePixelRatio; + canvas!.height = height * devicePixelRatio; + ctx!.scale(devicePixelRatio, devicePixelRatio); + } + window.addEventListener('resize', resize); + resize(); + + function draw() { + if (!ctx) return; + ctx.fillStyle = '#E9EFED'; + ctx.fillRect(0, 0, width, height); + rotAngle += 0.0008; + const fl = 1000, cx = width / 2, cy = height / 2, t = Date.now() * 0.001; + const mx = mouseRef.current.x, my = mouseRef.current.y; + + const proj = nodes.map(n => { + const ny = n.oy + Math.sin(t * 0.4 + n.seed) * 15; + let x = n.ox * Math.cos(rotAngle) - n.oz * Math.sin(rotAngle); + let z = n.oz * Math.cos(rotAngle) + n.ox * Math.sin(rotAngle); + x -= mx * (z + fl) * 0.02; + const y2 = ny - my * (z + fl) * 0.02; + const sc = fl / (fl + z); + return { x: x * sc + cx, y: y2 * sc + cy - parallaxYRef.current, z, sc, n }; + }).sort((a, b) => b.z - a.z); + + ctx.globalCompositeOperation = 'source-over'; + ctx.lineWidth = 0.5; + for (let i = 0; i < proj.length; i++) { + for (let j = i + 1; j < proj.length; j++) { + const p1 = proj[i], p2 = proj[j]; + const d = Math.hypot(p1.x - p2.x, p1.y - p2.y); + if (d < 70 * p1.sc) { + const a = (1 - d / (70 * p1.sc)) * 0.15 * Math.min(1, p1.sc); + ctx.strokeStyle = `rgba(156,163,175,${a})`; + ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke(); + } + } } + proj.forEach(p => { + if (p.z > -fl) { + const breathe = 0.92 + 0.08 * Math.sin(t * 0.6 + p.n.seed); + const r = p.n.radius * p.sc * breathe; + if (r > 0.1) { + const fogA = p.z > 500 ? Math.max(0, 1 - (p.z - 500) / 500) : 1; + ctx.globalAlpha = fogA; + ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); ctx.fillStyle = p.n.color; ctx.fill(); + } + } + }); + ctx.globalAlpha = 1; + animId = requestAnimationFrame(draw); } - load(); - }, [userId, userReady]); - - // Pick a random quote on the client only (avoids SSR/client hydration mismatch) - useEffect(() => { - setQuote(QUOTES[Math.floor(Math.random() * QUOTES.length)]); + draw(); + return () => { window.removeEventListener('resize', resize); cancelAnimationFrame(animId); }; }, []); - // Typing animation for greeting + // Mouse + scroll useEffect(() => { - const firstName = userName.split(' ')[0]; - const greeting = `${getTimeGreeting()}, ${firstName}.`; - let i = 0; - setDisplayedGreeting(''); - setGreetingDone(false); - setCursorVisible(true); - const interval = setInterval(() => { - i++; - setDisplayedGreeting(greeting.slice(0, i)); - if (i >= greeting.length) { - clearInterval(interval); - setTimeout(() => setGreetingDone(true), 300); + const onMouse = (e: MouseEvent) => { + mouseRef.current = { x: (e.clientX / window.innerWidth - 0.5) * 2, y: (e.clientY / window.innerHeight - 0.5) * 2 }; + }; + const onScroll = () => { + const sy = window.scrollY; + if (heroContentRef.current && sy < window.innerHeight) { + heroContentRef.current.style.transform = `translateY(${sy * -0.3}px)`; + parallaxYRef.current = sy * 0.1; } - }, 55); - return () => clearInterval(interval); - }, [userName]); + if (navRef.current) { + if (sy > 60) { navRef.current.classList.add('shadow-sm'); navRef.current.style.background = 'rgba(255,255,255,0.9)'; } + else { navRef.current.classList.remove('shadow-sm'); navRef.current.style.background = 'rgba(233,239,237,0.8)'; } + } + }; + document.addEventListener('mousemove', onMouse); + window.addEventListener('scroll', onScroll); + return () => { document.removeEventListener('mousemove', onMouse); window.removeEventListener('scroll', onScroll); }; + }, []); - // Blinking cursor while typing + // Floating cards parallax useEffect(() => { - if (greetingDone) { - setCursorVisible(false); - return; + let animId: number; + function tick() { + const t = Date.now(); + const mx = mouseRef.current.x, my = mouseRef.current.y; + floatingCardsRef.current?.querySelectorAll('.floating-card').forEach(card => { + const baseRot = parseFloat(card.dataset.baseRot || '0'); + const dur = parseFloat(card.dataset.floatDur || '5000'); + const delay = parseFloat(card.dataset.floatDelay || '0'); + const floatY = Math.sin((t - delay) / dur * Math.PI * 2) * -8; + const rx = -my * 5, ry = mx * 5; + const par = window.scrollY * -0.3; + card.style.transform = `perspective(1000px) translateY(${floatY + par}px) rotateX(${rx}deg) rotateY(${ry}deg) rotateZ(${baseRot}deg)`; + }); + animId = requestAnimationFrame(tick); } - const blink = setInterval(() => setCursorVisible(v => !v), 530); - return () => clearInterval(blink); - }, [greetingDone]); + tick(); + return () => cancelAnimationFrame(animId); + }, []); + // Intersection observer for fade-ups useEffect(() => { - const el = containerRef.current; - if (!el) return; - const obs = new ResizeObserver(entries => { - const entry = entries[0]; - if (entry) setGraphDimensions({ width: entry.contentRect.width, height: entry.contentRect.height }); + const obs = new IntersectionObserver(entries => { + entries.forEach(entry => { + if (entry.isIntersecting) { + entry.target.classList.remove('opacity-0', 'translate-y-[30px]'); + if (entry.target.classList.contains('landing-stat-fade-up')) startCounters(entry.target as HTMLElement); + obs.unobserve(entry.target); + } + }); + }, { threshold: 0.2 }); + document.querySelectorAll('.landing-fade-up, .landing-stat-fade-up').forEach(el => { + el.classList.add('opacity-0', 'translate-y-[30px]', 'transition-all', 'duration-700', 'ease-out'); + obs.observe(el); }); - obs.observe(el); return () => obs.disconnect(); }, []); - const handleNodeClick = useCallback((node: GraphNode) => { - router.push(`/learn?topic=${encodeURIComponent(node.concept_name)}`); - }, [router]); + // Spotlight card mouse-follow + useEffect(() => { + const cards = document.querySelectorAll('.landing-spotlight-card'); + const handler = (e: MouseEvent) => { + const card = (e.currentTarget as HTMLElement); + const r = card.getBoundingClientRect(); + card.style.setProperty('--mouse-x', `${e.clientX - r.left}px`); + card.style.setProperty('--mouse-y', `${e.clientY - r.top}px`); + }; + cards.forEach(c => c.addEventListener('mousemove', handler)); + return () => cards.forEach(c => c.removeEventListener('mousemove', handler)); + }, []); - const handleFile = async (file: File) => { - setUploadFilename(file.name); - setUploadLoading(true); - setUploadWarnings([]); - setExtractedAssignments([]); - setFileProcessed(false); - setRawText(''); - setRawTextVisible(false); - try { - const form = new FormData(); - form.append('file', file); - const res = await extractSyllabus(form); - const mapped: Assignment[] = (res.assignments ?? []).map((a: any, i: number) => ({ - id: `extracted_${i}_${Date.now()}`, - title: a.title ?? '', - course_name: a.course_name ?? '', - due_date: a.due_date ?? '', - assignment_type: a.assignment_type ?? 'other', - notes: a.notes ?? null, - google_event_id: null, - })); - setExtractedAssignments(mapped); - setUploadWarnings(res.warnings ?? []); - setRawText(res.raw_text ?? ''); - setFileProcessed(true); - } catch (e: any) { - setUploadWarnings([e.message || 'Extraction failed']); - } finally { - setUploadLoading(false); - } - }; + // Sticky scroll cinema + useEffect(() => { + function onScroll() { + const sec = stickyRef.current; + if (!sec) return; + const rect = sec.getBoundingClientRect(); + const progress = Math.max(0, Math.min(1, -rect.top / (rect.height - window.innerHeight))); + const s1 = step1Ref.current, s2 = step2Ref.current, s3 = step3Ref.current, sn = stepNumRef.current; + if (!s1 || !s2 || !s3 || !sn) return; + + function activate(el: HTMLDivElement, num: string) { + el.classList.remove('opacity-0', 'pointer-events-none', 'translate-y-[60px]', '-translate-y-[60px]'); + sn!.textContent = num; + } + function deactivateTop(el: HTMLDivElement) { + el.classList.add('opacity-0', 'pointer-events-none', '-translate-y-[60px]'); + el.classList.remove('translate-y-[60px]'); + } + function deactivateBottom(el: HTMLDivElement) { + el.classList.add('opacity-0', 'pointer-events-none', 'translate-y-[60px]'); + el.classList.remove('-translate-y-[60px]'); + } - const handleSave = async () => { - setSaving(true); - try { - await saveAssignments(userId, extractedAssignments); - setAllAssignments(prev => { - const ids = new Set(prev.map(a => a.id)); - return [...prev, ...extractedAssignments.filter(a => !ids.has(a.id))]; - }); - setSaved(true); - setTimeout(() => { - setSaved(false); - closeUpload(); - }, 1500); - } catch (e) { - console.error(e); - } finally { - setSaving(false); + if (progress < 0.33) { activate(s1, '01'); deactivateBottom(s2); deactivateBottom(s3); } + else if (progress < 0.66) { deactivateTop(s1); activate(s2, '02'); deactivateBottom(s3); } + else { deactivateTop(s1); deactivateTop(s2); activate(s3, '03'); } } - }; - - const closeUpload = () => { - setShowUpload(false); - setUploadFilename(''); - setUploadLoading(false); - setUploadWarnings([]); - setExtractedAssignments([]); - setFileProcessed(false); - setRawText(''); - setRawTextVisible(false); - }; + window.addEventListener('scroll', onScroll); + return () => window.removeEventListener('scroll', onScroll); + }, []); - const handleAddCourse = async () => { - const name = newCourseName.trim(); - if (!name) return; - setCourseError(''); - setCourseAdding(true); - try { - // Pick a preset color not already used by any existing course - const usedColors = new Set(Object.values(courseColorMap)); - const pickedColor = PRESET_COURSE_COLORS.find(c => !usedColors.has(c)) ?? PRESET_COURSE_COLORS[0]; - const res = await addCourse(userId, name, pickedColor); - if (res.already_existed) { - setCourseError(`"${name}" is already in your course list.`); - } else { - setNewCourseName(''); - const updated = await getCourses(userId); - setCourseList(updated.courses); - const colorMap: Record = {}; - updated.courses.forEach(c => { if (c.color) colorMap[c.course_name] = c.color; }); - setCourseColorMap(colorMap); - // Refresh graph so the new subject-root node appears immediately - const graphData = await getGraph(userId); - setNodes(graphData.nodes); - setEdges(graphData.edges); + function startCounters(container: HTMLElement) { + container.querySelectorAll('.counter, .counter-float').forEach(el => { + if (el.classList.contains('counted')) return; + el.classList.add('counted'); + const target = parseFloat(el.dataset.target || '0'); + const isFloat = el.classList.contains('counter-float'); + const dur = 1500, startT = performance.now(); + function update(now: number) { + const p = Math.min((now - startT) / dur, 1); + const ease = 1 - Math.pow(1 - p, 4); + el.textContent = isFloat ? (target * ease).toFixed(1) : Math.floor(target * ease).toLocaleString(); + if (p < 1) requestAnimationFrame(update); + else el.textContent = isFloat ? target.toFixed(1) : target.toLocaleString(); } - } catch (e: any) { - setCourseError(e.message || 'Failed to add course.'); - } finally { - setCourseAdding(false); - } - }; + requestAnimationFrame(update); + }); + } - const handleColorChange = async (courseName: string, newHex: string) => { - if (!/^#[0-9a-fA-F]{6}$/.test(newHex)) return; - try { - await updateCourseColor(userId, courseName, newHex); - setCourseList(prev => prev.map(c => c.course_name === courseName ? { ...c, color: newHex } : c)); - setCourseColorMap(prev => ({ ...prev, [courseName]: newHex })); - setEditingColorFor(null); - } catch (e) { - console.error(e); - } - }; + function openModal(mode: 'signin' | 'signup') { setModalMode(mode); setModalStep(1); setModalOpen(true); } + function closeModal() { setModalOpen(false); } + function handleGoogleContinue() { if (modalMode === 'signin') closeModal(); else setModalStep(2); } + function handleEmailContinue() { if (modalMode === 'signin') closeModal(); else setModalStep(2); } + function addClass() { const v = classInput.trim(); if (v) { setClassChips(prev => [...prev, v]); setClassInput(''); } } + function addSuggested(name: string) { setClassChips(prev => [...prev, name]); } - const handleDeleteCourse = async (courseName: string) => { - setCourseDeleting(courseName); - try { - await deleteCourse(userId, courseName); - setCourseList(prev => prev.filter(c => c.course_name !== courseName)); - // Refresh graph so the removed subject-root node disappears - const graphData = await getGraph(userId); - setNodes(graphData.nodes); - setEdges(graphData.edges); - } catch (e) { - console.error(e); - } finally { - setCourseDeleting(null); - } - }; + if (userReady && isAuthenticated) return null; return ( - <> -
- - {/* ── Left panel: Course list ─────────────────────────────────────── */} -
-

- Courses -

- - {loading ? ( -
Loading…
- ) : courseList.length === 0 ? ( -
No courses yet
- ) : ( - courseList.map(course => { - const subject = course.course_name; - const c = getCourseColor(subject, course.color); - const conceptNodes = nodes.filter(n => n.subject === subject && !n.is_subject_root && n.mastery_tier !== 'subject_root'); - const avgMastery = conceptNodes.length > 0 - ? conceptNodes.reduce((s, n) => s + n.mastery_score, 0) / conceptNodes.length - : 0; - const pct = Math.round(avgMastery * 100); - - // 5 soonest upcoming assignments for this course (case-insensitive match) - const courseAssignments = allAssignments - .filter(a => a.course_name?.toLowerCase() === subject.toLowerCase() && a.due_date) - .sort((a, b) => a.due_date.localeCompare(b.due_date)) - .slice(0, 5); - - const isCollapsed = collapsedCourses.has(subject); - - return ( -
- {/* Course name row — clickable to collapse */} - - - {/* Progress bar */} -
-
-
- - {pct}% mastery - - - {/* Upcoming assignments — always rendered, collapsed via maxHeight */} -
-
- {courseAssignments.length === 0 ? ( - No upcoming assignments - ) : courseAssignments.map(a => ( -
- {/* Assignment title in course color */} -
- {a.title} -
- {/* Category / type + due date */} -
- - {a.assignment_type} - - {formatDueDate(a.due_date)} -
-
- ))} -
-
-
- ); - }) - )} +
+
+ + {/* ═══ Navbar ═══ */} + - {/* ── Center: Greeting + Graph + Upcoming ────────────────────────── */} -
- - {/* Header: Typed Greeting + Quote + Action Buttons */} -
-

- {displayedGreeting} - - | - -

+ {/* ═══ Hero Section ═══ */} +
+ -

- {quote} -

- -
- - Start Learning - - - + {/* Floating Glass Accent Cards */} +
+
+ CS 101 +
+
+ 55% mastered
- {/* Knowledge Graph */}
- {loading ? ( -
- Loading graph… -
- ) : ( - - )} - - {/* AI "learn next" suggestion popup */} - {suggestConcept && suggestNode && ( -
-
- -
-

- AI Recommendation -

-

- {suggestConcept} -

-

- Based on your knowledge graph, this concept will have the highest impact on your mastery. -

-
-
-
- - -
-
- )} +
+
Mastered
+
Learning
+
Struggling
+
Unexplored
+
- {/* Upcoming assignments strip */} -
-
-

- Upcoming -

- - View Calendar - +
+
+ + Quick Quiz +
+
+ + Study Room
- {allAssignments.length === 0 ? ( -

No upcoming assignments

- ) : ( -
- {allAssignments.slice(0, 4).map(a => { - const c = getCourseColor(a.course_name, courseColorMap[a.course_name]); - return ( -
- - {formatDueDate(a.due_date)} - - - {a.course_name} - - {a.title} -
- ); - })} -
- )}
- {/* ── Right: Sidebar ──────────────────────────────────────────────── */} -
- - {/* User header + streak */} -
-

{userName}

- - {/* Streak count */} -
- 🔥 - - {stats?.streak ?? 0} - - day streak -
- - {/* 7-day week strip */} -
- {weekInfo.dates.map(({ label, iso, isToday, isFuture }) => { - const isActive = activeDaysThisWeek.has(iso); - return ( -
- {/* Day label */} - - {label} - - {/* Fire or empty ring */} - {isActive ? ( - 🔥 - ) : ( -
- )} -
- ); - })} + {/* Hero Content */} +
+
+
+
+ AI-Powered Learning
- {/* Stats */} - {stats && ( -
-

- Knowledge -

- {(['mastered', 'learning', 'struggling', 'unexplored'] as const).map(tier => ( -
-
- - {stats[tier]} {STATS_LABELS[tier]} - -
- ))} -
- )} +

+ {heroText1 || '\u00A0'} +

- {/* Recommendations */} - {recommendations.length > 0 && ( -
-

- Learn Next -

- {recommendations.map(rec => { - const node = nodes.find(n => n.concept_name === rec.concept_name); - return ( - - {rec.concept_name} - - {node ? getMasteryLabel(node.mastery_score) : '0%'} - - - ); - })} -
- )} +

+ {heroText2 || '\u00A0'} +

- {/* Actions */} -
- - Quick Quiz - - - Study Room - +
+ +
+
- {/* Recent activity */} - {nodes.length > 0 && ( -
-

- Recent Activity -

- {nodes - .filter(n => n.last_studied_at) - .sort((a, b) => (b.last_studied_at ?? '').localeCompare(a.last_studied_at ?? '')) - .slice(0, 4) - .map(n => ( -
- - {n.concept_name} — {getMasteryLabel(n.mastery_score)} - - - {formatRelativeTime(n.last_studied_at)} - + {/* Scroll Indicator */} +
+
+ SCROLL +
+
+ + {/* ═══ Features Section ═══ */} +
+ +
- {/* ── Upload Assignments Modal ────────────────────────────────────────── */} - {showUpload && ( -
{ if (e.target === e.currentTarget) closeUpload(); }} - > + {/* ═══ How It Works (Sticky Scroll Cinema) ═══ */} +
+
-
+
+
+ Step 01 +

Sign Up & Add Courses

+

Connect with Google in one click. Tell us your school and year. Add your classes. Takes 30 seconds.

+
+
+ Step 02 +

Upload Your Materials

+

Drop in a syllabus, textbook PDF, or lecture notes. Our AI reads everything, extracts concepts, and builds your knowledge map instantly.

+
+
+ Step 03 +

Start Growing

+

Follow your personalized learning path. Take adaptive quizzes. Watch your knowledge graph come alive, node by node turning green.

+
+
+
+
+ + {/* ═══ Stats Bar ═══ */} +
+
+ + {/* ═══ Final CTA ═══ */} +
+
+ + {/* ═══ Footer ═══ */} + + + {/* ═══ Onboarding Modal ═══ */} + {modalOpen && ( +
+
+
+
+ -

- Upload Assignments -

-

- Upload a syllabus PDF to automatically extract assignment deadlines and names. Edit before saving. -

- -
-

- Syllabus File -

- - {uploadWarnings.map((w, i) => ( -

{w}

- ))} -
+ {/* Progress Dots */} + {modalMode === 'signup' && ( +
+
+
2 ? 'bg-[#1B6C42]/30' : 'bg-gray-200'}`} /> +
+
+ )} - {fileProcessed && ( -
-
-

- {extractedAssignments.length > 0 - ? `Detected ${extractedAssignments.length} assignment${extractedAssignments.length !== 1 ? 's' : ''} — edit before saving` - : 'No assignments detected — add rows manually'} +

+ {/* Step 1: Auth */} +
+

+ {modalMode === 'signin' ? 'Welcome back' : 'Welcome to Sapling'} +

+

+ {modalMode === 'signin' ? 'Sign in to continue' : 'Start your learning journey'}

- +
+
+ or +
+
+ + +

+ {modalMode === 'signin' ? "Don't have an account? " : 'Already have an account? '} + +

- - - {rawText && ( -
- - {rawTextVisible && ( -
-                        {rawText}
-                      
- )} + {/* Step 2: School Info */} +
+

About You

+

Help us personalize your experience

+
+ +
- )} -
- )} -
-
- )} - {/* ── Courses Modal ───────────────────────────────────────────────────── */} - {showCourses && ( -
{ if (e.target === e.currentTarget) { setShowCourses(false); setCourseError(''); setNewCourseName(''); setEditingColorFor(null); } }} - > -
- {/* Close */} - - -

- My Courses -

-

- Courses appear as large hub nodes on your knowledge tree. Deleting a course removes all its concept nodes. -

+
+ + +
+ +
- {/* Course list */} -
- {courseList.length === 0 ? ( -

No courses added yet.

- ) : ( - courseList.map(c => { - const color = getCourseColor(c.course_name, c.color); - const isDeleting = courseDeleting === c.course_name; - const isEditingColor = editingColorFor === c.course_name; - return ( -
- {/* Main row */} -
-
- {/* Color swatch — click to open picker */} -
- +
+
+ {classChips.map((chip, idx) => ( +
+ {chip} +
- - {/* Inline color picker (animated via maxHeight) */} -
-
- {/* Preset swatches */} -

- Preset colours -

-
- {PRESET_COURSE_COLORS.map(hex => ( -
- {/* Hex input */} -
-
- setColorHexInput(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') handleColorChange(c.course_name, colorHexInput); }} - placeholder="#2563eb" - style={{ - flex: 1, padding: '4px 8px', border: '1px solid rgba(107,114,128,0.25)', - borderRadius: '4px', fontSize: '12px', fontFamily: 'monospace', - outline: 'none', color: '#111827', - }} - /> - -
-
-
-
- ); - }) - )} -
- - {/* Divider */} -
- - {/* Add new course */} -

- Add a Course -

-
- { setNewCourseName(e.target.value); setCourseError(''); }} - onKeyDown={e => { if (e.key === 'Enter') handleAddCourse(); }} - placeholder="e.g. Calculus II, World History…" - style={{ - flex: 1, - padding: '8px 12px', - border: '1px solid rgba(107,114,128,0.25)', - borderRadius: '6px', - fontSize: '13px', - outline: 'none', - fontFamily: 'inherit', - color: '#111827', - }} - /> - + ))} +
+
+ Popular: + {['CS 101', 'Calculus I', 'Physics I'].map(s => ( + addSuggested(s)} className="border border-gray-200/60 text-gray-500 hover:text-gray-900 hover:bg-gray-50 rounded-full px-3 py-1.5 text-xs cursor-pointer transition-all">{s} + ))} +
+ + +
+
- {courseError && ( -

{courseError}

- )}
)} - - ); -} - -export default function Dashboard() { - return ( - - - +
); } diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 487f0580..633b5373 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -7,7 +7,7 @@ import { useUser } from '@/context/UserContext'; import { getRecommendations } from '@/lib/api'; const LINKS = [ - { href: '/', label: 'Dashboard' }, + { href: '/dashboard', label: 'Dashboard' }, { href: '/learn', label: 'Learn' }, { href: '/flashcards', label: 'Flashcards' }, { href: '/library', label: 'Library' }, @@ -36,13 +36,16 @@ export default function Navbar() { return () => document.removeEventListener('mousedown', handleClickOutside); }, []); - // Redirect unauthenticated users to signin + const isLandingPage = pathname === '/'; + useEffect(() => { - if (userReady && !isAuthenticated && pathname !== '/signin' && pathname !== '/signin/callback') { + if (userReady && !isAuthenticated && pathname !== '/' && pathname !== '/signin' && pathname !== '/signin/callback') { router.push('/signin'); } }, [userReady, isAuthenticated, pathname, router]); + if (isLandingPage) return null; + const handleSuggest = async () => { setSuggesting(true); try { @@ -86,7 +89,7 @@ export default function Navbar() { zIndex: 50, }} > - + Sapling {LINKS.map(link => { - const active = pathname === link.href || (link.href !== '/' && pathname.startsWith(link.href)); + const active = pathname === link.href || (link.href !== '/dashboard' && pathname.startsWith(link.href)); return (