diff --git a/backend/.gitignore b/backend/.gitignore index 18d787d..4900981 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -10,3 +10,4 @@ venv/ ENV/ env.bak/ venv.bak/ +celerybeat-schedule* diff --git a/backend/celerybeat-schedule b/backend/celerybeat-schedule deleted file mode 100644 index a9c5eb0..0000000 Binary files a/backend/celerybeat-schedule and /dev/null differ diff --git a/backend/main.py b/backend/main.py index f20490c..db78586 100644 --- a/backend/main.py +++ b/backend/main.py @@ -456,6 +456,8 @@ class UserUpdate(BaseModel): name: Optional[str] = None target_exam: Optional[models.ExamEnum] = None student_class: Optional[models.ClassEnum] = None + school: Optional[str] = None + state: Optional[str] = None @app.put("/api/users/{user_id}", response_model=models.User, tags=["Users"]) def update_user_profile(user_id: UUID, payload: UserUpdate, session: Session = Depends(get_session)): @@ -468,14 +470,36 @@ def update_user_profile(user_id: UUID, payload: UserUpdate, session: Session = D if payload.target_exam is not None: user.target_exam = payload.target_exam if payload.student_class is not None: - # Note: mapping alias 'class' in User class to 'student_class' user.student_class = payload.student_class + if payload.school is not None: + user.school = payload.school + if payload.state is not None: + user.state = payload.state session.add(user) session.commit() session.refresh(user) return user +from fastapi import UploadFile, File +from storage import upload_file + +@app.post("/api/users/{user_id}/upload-avatar", tags=["Users"]) +async def upload_user_avatar(user_id: UUID, file: UploadFile = File(...), session: Session = Depends(get_session)): + user = session.get(models.User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + contents = await file.read() + file_extension = file.filename.split(".")[-1] if "." in file.filename else "jpg" + object_name = f"avatar_{user_id}.{file_extension}" + public_url = upload_file(contents, object_name, content_type=file.content_type) + + if not public_url: + raise HTTPException(status_code=500, detail="Failed to upload image to Minio.") + + return {"url": public_url} + @app.get("/api/tests", response_model=List[models.Test], tags=["CBT Engine"]) def list_tests(exam_pattern: Optional[models.ExamEnum] = None, session: Session = Depends(get_session)): query = select(models.Test) diff --git a/backend/requirements.txt b/backend/requirements.txt index de97ce3..d3c0d91 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,3 +10,4 @@ pyjwt==2.8.0 celery==5.3.6 openai==1.14.0 httpx==0.27.2 +boto3==1.34.0 diff --git a/backend/seed.py b/backend/seed.py index a4f9722..4c9c809 100644 --- a/backend/seed.py +++ b/backend/seed.py @@ -10,24 +10,65 @@ def seed_local_database(): # Ensure tables are created init_db() + has_data = False with Session(engine) as session: # Prevent double seeding in local testing environment if session.exec(select(models.Question)).first() is not None: - print("Local environment database already contains data components. Dropping all and reseeding...") - import sqlmodel - sqlmodel.SQLModel.metadata.drop_all(engine) - init_db() + has_data = True + + if has_data: + print("Local environment database already contains data components. Dropping all and reseeding...") + import sqlmodel + sqlmodel.SQLModel.metadata.drop_all(engine) + init_db() + + print("Seeding baseline content entities...") + + with Session(engine) as session: + # 0. Create the baseline student users (Free, Pro, AI Coach) + free_student_id = UUID("11111111-1111-1111-1111-111111111111") + free_student = models.User( + id=free_student_id, + phone="8888888888", + email="free@pragativeer.com", + password_hash="mock_hash", + name="Amit Kumar (Free Tier)", + student_class=models.ClassEnum.TWELFTH, + medium=models.MediumEnum.ENGLISH, + target_exam=models.ExamEnum.JEE_MAIN, + city="Patna", + state="Bihar", + tier=models.TierEnum.FREE, + streak_count=0, + last_active=datetime.utcnow() + ) + session.add(free_student) + + pro_student_id = UUID("22222222-2222-2222-2222-222222222222") + pro_student = models.User( + id=pro_student_id, + phone="7777777777", + email="pro@pragativeer.com", + password_hash="mock_hash", + name="Priya Singh (Pro Tier)", + student_class=models.ClassEnum.TWELFTH, + medium=models.MediumEnum.ENGLISH, + target_exam=models.ExamEnum.NEET, + city="Kota", + state="Rajasthan", + tier=models.TierEnum.PRO, + streak_count=3, + last_active=datetime.utcnow() + ) + session.add(pro_student) - print("Seeding baseline content entities...") - - # 0. Create the baseline student user (seeded target: JEE_MAIN) student_id = UUID("b0e17378-864d-439e-8806-708dc48a891b") student_user = models.User( id=student_id, phone="9999999999", email="student@pragativeer.com", password_hash="mock_hash", - name="Rahul Sharma", + name="Rahul Sharma (AI Coach Tier)", student_class=models.ClassEnum.TWELFTH, medium=models.MediumEnum.ENGLISH, target_exam=models.ExamEnum.JEE_MAIN, diff --git a/backend/storage.py b/backend/storage.py new file mode 100644 index 0000000..7447d61 --- /dev/null +++ b/backend/storage.py @@ -0,0 +1,91 @@ +import os +import logging +import boto3 +from botocore.client import Config +from botocore.exceptions import ClientError +import json + +logger = logging.getLogger("storage") + +# Read settings from environment +MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minio:9000") +MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minio_admin") +MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minio_secure_password_2026") +MINIO_BUCKET = os.getenv("MINIO_BUCKET", "pragativeer-uploads") + +# Initialize S3/Minio client +# Since backend runs inside Docker, endpoint_url must point to 'http://minio:9000' +# However, from the client browser, it will be reached via 'http://localhost:9000' +s3_client = boto3.client( + "s3", + endpoint_url=f"http://{MINIO_ENDPOINT}", + aws_access_key_id=MINIO_ACCESS_KEY, + aws_secret_access_key=MINIO_SECRET_KEY, + config=Config(signature_version="s3v4"), + region_name="us-east-1" +) + +def ensure_bucket_exists(): + try: + # Check if bucket exists + s3_client.head_bucket(Bucket=MINIO_BUCKET) + logger.info(f"Minio bucket '{MINIO_BUCKET}' already exists.") + except ClientError as e: + error_code = e.response["Error"]["Code"] + if error_code == "404": + try: + # Create bucket + s3_client.create_bucket(Bucket=MINIO_BUCKET) + logger.info(f"Successfully created Minio bucket '{MINIO_BUCKET}'.") + + # Set public read policy for direct browser access + bucket_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "PublicReadGetObject", + "Effect": "Allow", + "Principal": "*", + "Action": ["s3:GetObject"], + "Resource": [f"arn:aws:s3:::{MINIO_BUCKET}/*"] + } + ] + } + s3_client.put_bucket_policy( + Bucket=MINIO_BUCKET, + Policy=json.dumps(bucket_policy) + ) + logger.info(f"Public read policy applied to Minio bucket '{MINIO_BUCKET}'.") + except Exception as ex: + logger.error(f"Failed to create Minio bucket: {ex}") + else: + logger.error(f"Error checking Minio bucket: {e}") + +def upload_file(file_content: bytes, object_name: str, content_type: str = "application/octet-stream") -> str: + """Uploads file content to Minio and returns the direct public URL.""" + try: + ensure_bucket_exists() + s3_client.put_object( + Bucket=MINIO_BUCKET, + Key=object_name, + Body=file_content, + ContentType=content_type + ) + + # Construct public URL (from browser's point of view, it connects to localhost:9000) + # We can resolve this dynamically by returning a path like /api/uploads/ or direct localhost:9000 link + public_url = f"http://localhost:9000/{MINIO_BUCKET}/{object_name}" + logger.info(f"Successfully uploaded {object_name} to Minio. URL: {public_url}") + return public_url + except Exception as e: + logger.error(f"Failed to upload file to Minio: {e}") + return "" + +def get_file(object_name: str) -> bytes: + """Downloads file content from Minio.""" + try: + response = s3_client.get_object(Bucket=MINIO_BUCKET, Key=object_name) + return response["Body"].read() + except Exception as e: + logger.error(f"Failed to get file from Minio: {e}") + return b"" diff --git a/docker-compose.yml b/docker-compose.yml index 6a1b3f2..1a59856 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,6 +44,10 @@ services: - DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@postgres:5432/pragativeer_prod - REDIS_URL=redis://redis:6379/0 - ENV=development + - MINIO_ENDPOINT=minio:9000 + - MINIO_ACCESS_KEY=minio_admin + - MINIO_SECRET_KEY=minio_secure_password_2026 + - MINIO_BUCKET=pragativeer-uploads env_file: - ./backend/.env depends_on: @@ -63,6 +67,10 @@ services: environment: - DATABASE_URL=postgresql://pragativeer_admin:local_secure_password_2026@postgres:5432/pragativeer_prod - REDIS_URL=redis://redis:6379/0 + - MINIO_ENDPOINT=minio:9000 + - MINIO_ACCESS_KEY=minio_admin + - MINIO_SECRET_KEY=minio_secure_password_2026 + - MINIO_BUCKET=pragativeer-uploads env_file: - ./backend/.env depends_on: @@ -107,9 +115,25 @@ services: networks: - pragativeer_network + minio: + image: minio/minio:latest + container_name: pragativeer_storage + ports: + - "9000:9000" + - "9001:9001" + volumes: + - miniodata:/data + environment: + MINIO_ROOT_USER: minio_admin + MINIO_ROOT_PASSWORD: minio_secure_password_2026 + command: server /data --console-address ":9001" + networks: + - pragativeer_network + volumes: pgdata: redisdata: + miniodata: networks: pragativeer_network: diff --git a/next.config.ts b/next.config.ts index e9ffa30..93c0267 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,14 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + async rewrites() { + return [ + { + source: "/api/:path*", + destination: "http://backend:8000/api/:path*", + }, + ]; + }, }; export default nextConfig; diff --git a/src/app/dashboard/ai-coach/page.tsx b/src/app/dashboard/ai-coach/page.tsx index 2e23754..dfb768c 100644 --- a/src/app/dashboard/ai-coach/page.tsx +++ b/src/app/dashboard/ai-coach/page.tsx @@ -1,4 +1,4 @@ -"use client" +"use client"; import { useEffect, useState } from "react" import { MistakeHeatmap } from "@/components/AI/MistakeHeatmap" @@ -11,20 +11,22 @@ import { ProgressShare } from "@/components/AI/ProgressShare" import { MistakeReviewCard, MistakePayload } from "@/components/AI/MistakeReviewCard" import { Card, CardContent } from "@/components/ui/card" import { Button } from "@/components/ui/button" -import { BrainCircuit, BookOpen, Calculator, Target, RefreshCw, CheckCircle2 } from "lucide-react" - -// Hardcode mock UUID for demo fetching -const USER_ID = "b0e17378-864d-439e-8806-708dc48a891b" +import { BrainCircuit, BookOpen, Calculator, Target, RefreshCw, CheckCircle2, Lock } from "lucide-react" +import { useActiveUser } from "@/hooks/useActiveUser" +import Link from "next/link" export default function AICoachDashboard() { const [mistakes, setMistakes] = useState([]) const [currentIndex, setCurrentIndex] = useState(0) const [loading, setLoading] = useState(true) + const { userId, user, hasAccessTo, loading: userLoading } = useActiveUser(); + const fetchMistakes = async () => { + if (!userId) return setLoading(true) try { - const res = await fetch(`http://localhost:8000/api/mistakes/today?user_id=${USER_ID}`) + const res = await fetch(`/api/mistakes/today?user_id=${userId}`) if (!res.ok) throw new Error("Failed to fetch") const data = await res.json() setMistakes(data) @@ -38,11 +40,13 @@ export default function AICoachDashboard() { } useEffect(() => { - fetchMistakes() - }, []) + if (!userLoading && userId) { + fetchMistakes() + } + }, [userId, userLoading]) const submitReview = async (mistakeId: string, selectedAnswer: string) => { - const res = await fetch("http://localhost:8000/api/mistakes/review", { + const res = await fetch("/api/mistakes/review", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mistake_id: mistakeId, selected_answer: selectedAnswer }) @@ -61,6 +65,33 @@ export default function AICoachDashboard() { } } + if (userLoading) { + return
Loading AI Coach...
+ } + + if (!hasAccessTo("ai-mistake-tracking")) { + return ( +
+
+ +
+
+

AI Coach Workspace

+

+ Mistake tracking, Spaced Repetition (SM-2), AI Tutor Chats, and Readiness scores are premium features reserved for **AI Coach** subscribers. +

+
+
+ + + +
+
+ ) + } + return (
diff --git a/src/app/dashboard/cbt/page.tsx b/src/app/dashboard/cbt/page.tsx index db309ec..361c61a 100644 --- a/src/app/dashboard/cbt/page.tsx +++ b/src/app/dashboard/cbt/page.tsx @@ -5,7 +5,8 @@ import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { Activity, Clock, PlayCircle, Search } from "lucide-react"; +import { Activity, Clock, PlayCircle, Search, Lock } from "lucide-react"; +import { useActiveUser } from "@/hooks/useActiveUser"; interface TestData { id: string; @@ -22,27 +23,31 @@ export default function CBTPage() { const [search, setSearch] = useState(""); const [targetExam, setTargetExam] = useState(""); const [attempts, setAttempts] = useState([]); + const [showUpgradeModal, setShowUpgradeModal] = useState(false); - const userId = "b0e17378-864d-439e-8806-708dc48a891b"; + const { userId, user, hasAccessTo, loading: userLoading } = useActiveUser(); useEffect(() => { + if (userLoading || !userId) return; + async function loadCBTExams() { try { // 1. Fetch user target exam - const userRes = await fetch(`http://localhost:8000/api/users/${userId}`); + const userRes = await fetch(`/api/users/${userId}`); if (!userRes.ok) throw new Error("Failed to load user target exam"); const userData = await userRes.json(); const pattern = userData.target_exam; setTargetExam(pattern === "NEET" ? "NEET" : "JEE Main"); // 2. Fetch tests filtered by exam - const testRes = await fetch(`http://localhost:8000/api/tests?exam_pattern=${pattern}`); - if (!testRes.ok) throw new Error("Failed to fetch tests"); - const testsData = await testRes.json(); - setExams(testsData); + const testRes = await fetch(`/api/tests?exam_pattern=${pattern}&user_id=${userId}`); + if (testRes.ok) { + const testsData = await testRes.json(); + setExams(testsData); + } // 3. Fetch user attempts - const attemptsRes = await fetch(`http://localhost:8000/api/users/${userId}/attempts`); + const attemptsRes = await fetch(`/api/users/${userId}/attempts`); if (attemptsRes.ok) { const attemptsData = await attemptsRes.json(); setAttempts(attemptsData); @@ -54,7 +59,7 @@ export default function CBTPage() { } } loadCBTExams(); - }, []); + }, [userId, userLoading]); const filteredExams = exams.filter(exam => exam.title.toLowerCase().includes(search.toLowerCase()) @@ -109,19 +114,24 @@ export default function CBTPage() { {filteredExams.map((exam) => { const attempt = attemptsByTest[exam.id]; const isAttempted = !!attempt; + const isLocked = !hasAccessTo("weekly-test-series"); return ( - +
{exam.type} Test - {isAttempted && ( + {isLocked ? ( + + Premium + + ) : isAttempted ? ( Attempted - )} + ) : null}

{exam.title} @@ -130,11 +140,20 @@ export default function CBTPage() {
Available Now
{exam.total_time} Min • {exam.difficulty} Difficulty

- - - + ) : ( + + + + )} )})} @@ -185,6 +204,30 @@ export default function CBTPage() { )})} + + {showUpgradeModal && ( +
+
+
+ +
+

Upgrade to Premium Plan

+

+ This CBT Mock Test is a premium feature. Free users get access to 1 Daily Practice Sheet and 5 Topic-wise Micro-tests. Upgrade to Pro or AI Coach to unlock unlimited practice and full-length weekly test series! +

+
+ + + + +
+
+
+ )} ); } diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 30584e3..7a90eb9 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -1,7 +1,7 @@ "use client" import { ReactNode, useState, useEffect, useRef } from "react" -import { BookOpen, LayoutDashboard, Settings, FileText, Activity, LineChart, Trophy, LogOut, User, ChevronUp } from "lucide-react" +import { BookOpen, LayoutDashboard, Settings, FileText, Activity, LineChart, Trophy, LogOut, User, ChevronUp, Lock } from "lucide-react" import Link from "next/link" import Image from "next/image" import { usePathname } from "next/navigation" @@ -10,11 +10,16 @@ import { ThemeToggle } from "@/components/ThemeToggle" import { TierBadge } from "@/components/TierBadge" import { StreakCounter } from "@/components/StreakCounter" import { AITutorChat } from "@/components/AI/AITutorChat" +import { useActiveUser, DEMO_USERS } from "@/hooks/useActiveUser" +import { StreakOnboardingModal } from "@/components/Dashboard/StreakOnboardingModal" +import { Badge } from "@/components/ui/badge" export default function DashboardLayout({ children }: { children: ReactNode }) { const pathname = usePathname() const [dropdownOpen, setDropdownOpen] = useState(false) const dropdownRef = useRef(null) + + const { userId, user, switchUser, hasAccessTo, loading } = useActiveUser(); // Close dropdown when clicking outside useEffect(() => { @@ -27,6 +32,9 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { return () => document.removeEventListener("mousedown", handleClickOutside) }, []) + const userInitials = user ? user.name.split(" ").map(n => n[0]).join("").toUpperCase() : "DS" + const targetExamLabel = user?.target_exam === "JEE_Main" ? "JEE Main" : user?.target_exam === "JEE_Advanced" ? "JEE Advanced" : user?.target_exam === "NEET" ? "NEET" : "NEET/JEE" + return (
{/* Sidebar */} @@ -51,7 +59,13 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
-
- - - -
- - {dropdownOpen && ( + {dropdownOpen && user && (
-

Demo Student

-

demo@pragativeer.com

+

{user.name}

+

{user.email || user.phone}

Edit Profile - - Log out - +
)} @@ -111,50 +141,111 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { onClick={() => setDropdownOpen(!dropdownOpen)} className="w-full flex items-center justify-between p-2 rounded-xl bg-background/50 hover:bg-background transition-colors border border-border/50 shadow-sm mt-3" > -
-
- DS +
+
+ {userInitials}
-
-

Demo Student

-
+
+

{user ? user.name.split(" ")[0] : "Loading..."}

+
- +
- {/* Main Content */} -
-
- - + {/* Main Content Area */} +
+ {/* Top Header Static Bar */} +
+
+

+ {user ? `Welcome back, ${user.name.split(" ")[0]}! 👋` : "Welcome back!"} +

+ {user && ( + + {targetExamLabel} Target + + )} +
+ +
+ {/* Streak count moved to top header */} + {user && ( + + + + )} + + {/* Profile switcher dropdown for testing */} +
+ Demo Switcher: + +
+ +
+ + +
+
-
+ + {/* Scrollable page body */} +
{children}
{/* Global AI Tutor Chat */} - + {hasAccessTo("ai-tutor-chat") && } + + {/* Onboarding Motivation Popup */} +
) } -function NavItem({ href, icon, label, active }: { href: string, icon: React.ReactNode, label: string, active?: boolean }) { +function NavItem({ href, icon, label, active, isLocked }: { href: string, icon: React.ReactNode, label: string, active?: boolean, isLocked?: boolean }) { return ( - {icon} - {label} +
+ {icon} + {label} +
+ {isLocked && } ) } + +function BrainCircuitIcon() { + return ( + + + + + + + + + + ) +} diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index 600df78..d3b33aa 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -1,53 +1,85 @@ -"use client"; - import { useEffect, useState } from "react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { useActiveUser } from "@/hooks/useActiveUser"; +import { User as UserIcon, Upload, CheckCircle2 } from "lucide-react"; export default function SettingsPage() { - const [targetExam, setTargetExam] = useState<"JEE_Main" | "NEET">("JEE_Main"); - const [targetYear, setTargetYear] = useState("2026"); - const [firstName, setFirstName] = useState("Rahul"); - const [lastName, setLastName] = useState("Sharma"); - const [loading, setLoading] = useState(true); + const { userId, user, refreshUser, loading: userLoading } = useActiveUser(); + const [targetExam, setTargetExam] = useState<"JEE_Main" | "JEE_Advanced" | "NEET">("JEE_Main"); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [school, setSchool] = useState(""); + const [state, setState] = useState(""); + const [email, setEmail] = useState(""); + const [phone, setPhone] = useState(""); + + const [avatarUrl, setAvatarUrl] = useState(null); + const [uploading, setUploading] = useState(false); const [saving, setSaving] = useState(false); - - const userId = "b0e17378-864d-439e-8806-708dc48a891b"; + const [loading, setLoading] = useState(true); useEffect(() => { - async function fetchUserData() { - try { - const res = await fetch(`http://localhost:8000/api/users/${userId}`); - if (res.ok) { - const data = await res.json(); - setTargetExam(data.target_exam === "NEET" ? "NEET" : "JEE_Main"); - if (data.name) { - const parts = data.name.split(" "); - setFirstName(parts[0] || ""); - setLastName(parts.slice(1).join(" ") || ""); - } - } - } catch (err) { - console.error("Failed to load user profile:", err); - } finally { - setLoading(false); - } + if (userLoading || !user) return; + + setTargetExam(user.target_exam); + setSchool(user.school || ""); + setState(user.state || ""); + setEmail(user.email || ""); + setPhone(user.phone || ""); + setAvatarUrl(localStorage.getItem(`user_avatar_${user.id}`) || null); + + if (user.name) { + const parts = user.name.split(" "); + setFirstName(parts[0] || ""); + setLastName(parts.slice(1).join(" ") || ""); } - fetchUserData(); - }, []); + setLoading(false); + }, [user, userLoading]); + + const handleAvatarUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file || !userId) return; + + setUploading(true); + const formData = new FormData(); + formData.append("file", file); + + try { + const res = await fetch(`/api/users/${userId}/upload-avatar`, { + method: "POST", + body: formData, + }); + + if (!res.ok) throw new Error("Upload failed"); + const data = await res.json(); + setAvatarUrl(data.url); + localStorage.setItem(`user_avatar_${userId}`, data.url); + alert("Profile picture uploaded successfully to Minio!"); + } catch (err) { + console.error(err); + alert("Failed to upload profile picture."); + } finally { + setUploading(false); + } + }; const handleSave = async () => { + if (!userId) return; setSaving(true); try { - const res = await fetch(`http://localhost:8000/api/users/${userId}`, { + const res = await fetch(`/api/users/${userId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: `${firstName} ${lastName}`.trim(), target_exam: targetExam, + school: school.trim(), + state: state.trim(), }), }); if (res.ok) { + await refreshUser(); alert("Settings saved successfully!"); } else { alert("Failed to save settings: " + (await res.text())); @@ -60,117 +92,166 @@ export default function SettingsPage() { } }; - if (loading) { - return
Loading settings...
; + if (loading || userLoading) { + return
Loading settings...
; } return (

Account Settings

-

Manage your personal details and platform preferences.

+

Manage your personal details, school details, and platform preferences.

-
- +
+ {/* Profile photo upload tile */} + - Profile Details - Update your personal information. + Profile Photo + Upload photo to Minio storage. - -
-
- - setFirstName(e.target.value)} - className="w-full bg-secondary/50 border border-border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#00F0FF]" - /> -
-
- - setLastName(e.target.value)} - className="w-full bg-secondary/50 border border-border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#00F0FF]" - /> -
+ +
+ {avatarUrl ? ( + Avatar + ) : ( + + )} + {uploading && ( +
+ Uploading... +
+ )}
-
- - -
- + +
- - - Academic Preferences - Tailor the AI to your specific exam targets. - - -
- -
- -
+
+ {}} - className="accent-[#00F0FF] w-4 h-4" + type="text" + value={lastName} + onChange={(e) => setLastName(e.target.value)} + className="w-full bg-secondary/50 border border-border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#00F0FF]" /> - JEE Main & Adv - +
-
-
- - -
- -
-
+
+ + setSchool(e.target.value)} + className="w-full bg-secondary/50 border border-border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#00F0FF]" + /> +
+
+ + setState(e.target.value)} + className="w-full bg-secondary/50 border border-border rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#00F0FF]" + /> +
+
+ + +
+ + + + + + + Academic Preferences + Tailor the AI to your specific exam targets. + + +
+ +
+ + +
+
+ +
+
+
); diff --git a/src/app/globals.css b/src/app/globals.css index 81c049d..44e0632 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -44,23 +44,21 @@ --muted-foreground: #737373; } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - --border: #1f1f1f; - --input: #1f1f1f; - --ring: #D1FF4D; +.dark { + --background: #0a0a0a; + --foreground: #ededed; + --border: #1f1f1f; + --input: #1f1f1f; + --ring: #D1FF4D; - --primary: #D1FF4D; - --primary-foreground: #0a0a0a; - --secondary: #171717; - --secondary-foreground: #ededed; - --accent: #00F0FF; - --accent-foreground: #0a0a0a; - --muted: #171717; - --muted-foreground: #a3a3a3; - } + --primary: #D1FF4D; + --primary-foreground: #0a0a0a; + --secondary: #171717; + --secondary-foreground: #ededed; + --accent: #00F0FF; + --accent-foreground: #0a0a0a; + --muted: #171717; + --muted-foreground: #a3a3a3; } body { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 51f5ef6..9a5a7d4 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -29,6 +29,21 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} suppressHydrationWarning > + +