initial commit
41
.gitignore
vendored
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
36
README.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
109
app/(auth)/login/page.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Image from "next/image";
|
||||||
|
import BackgroundWrapper from "@/components/BackgroundWrapper";
|
||||||
|
import FormField from "@/components/FormField";
|
||||||
|
import { login } from "@/lib/auth";
|
||||||
|
import DestructibleAlert from "@/components/DestructibleAlert";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
|
const page = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { setToken } = useAuth();
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// For Rafeed
|
||||||
|
// Function to login a user. I've kept it in a barebones form right now, but you can just call the login function from /lib/auth.ts and pass on the form.
|
||||||
|
const loginUser = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
await login(form, setToken); // Call the login function
|
||||||
|
router.push("/home"); // Redirect on successful login
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
setError(error.message); // Handle error messages
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackgroundWrapper>
|
||||||
|
<div className="flex-1 min-h-screen">
|
||||||
|
<div className="min-h-screen overflow-y-auto">
|
||||||
|
<div className="min-h-full flex flex-col justify-center gap-10 mt-7 mx-6 py-8">
|
||||||
|
{/* Logo Container */}
|
||||||
|
<div
|
||||||
|
className="w-full self-center mt-7"
|
||||||
|
style={{ aspectRatio: "368/89" }}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/images/logo/logo.png"
|
||||||
|
alt="Logo"
|
||||||
|
width={368}
|
||||||
|
height={89}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Container */}
|
||||||
|
<div className="flex flex-col justify-between gap-10">
|
||||||
|
<div className="flex flex-col w-full gap-5">
|
||||||
|
<FormField
|
||||||
|
title="Email Address"
|
||||||
|
value={form.email}
|
||||||
|
placeholder="Enter your email address..."
|
||||||
|
handleChangeText={(e) => setForm({ ...form, email: e })}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
title="Password"
|
||||||
|
value={form.password}
|
||||||
|
placeholder="Enter a password"
|
||||||
|
handleChangeText={(e) => setForm({ ...form, password: e })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <DestructibleAlert text={error} extraStyles="" />}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/home")}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full h-14 flex justify-center items-center border border-[#113768] rounded-full bg-transparent hover:bg-[#113768] hover:text-white transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-medium"
|
||||||
|
style={{ fontFamily: "Montserrat, sans-serif" }}
|
||||||
|
>
|
||||||
|
{isLoading ? "Logging in..." : "Login"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Register Link */}
|
||||||
|
<p
|
||||||
|
className="text-center mb-[70px]"
|
||||||
|
style={{ fontFamily: "Montserrat, sans-serif" }}
|
||||||
|
>
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Link href="/register" className="text-[#276ac0] hover:underline">
|
||||||
|
Register here.
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BackgroundWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default page;
|
||||||
166
app/(auth)/register/page.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { register } from "@/lib/auth";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import BackgroundWrapper from "@/components/BackgroundWrapper";
|
||||||
|
import FormField from "@/components/FormField";
|
||||||
|
import DestructibleAlert from "@/components/DestructibleAlert";
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const { setToken } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: "",
|
||||||
|
institution: "",
|
||||||
|
sscRoll: "",
|
||||||
|
hscRoll: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleError = (error: any) => {
|
||||||
|
if (error?.detail) {
|
||||||
|
const match = error.detail.match(/Key \((.*?)\)=\((.*?)\)/);
|
||||||
|
if (match) {
|
||||||
|
const field = match[1];
|
||||||
|
const value = match[2];
|
||||||
|
return `The ${field} already exists. Please use a different value.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "An unexpected error occurred. Please try again.";
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = () => {
|
||||||
|
const { sscRoll, hscRoll, password } = form;
|
||||||
|
if (sscRoll === hscRoll) {
|
||||||
|
return "SSC Roll and HSC Roll must be unique.";
|
||||||
|
}
|
||||||
|
const passwordRegex =
|
||||||
|
/^(?=.*[A-Z])(?=.*[!@#$%^&*(),.?":{}|<>])[A-Za-z\d!@#$%^&*(),.?":{}|<>]{8,16}$/;
|
||||||
|
if (!passwordRegex.test(password)) {
|
||||||
|
return "Password must be 8-16 characters long, include at least one uppercase letter and one special character.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createUser = async () => {
|
||||||
|
const validationError = validateForm();
|
||||||
|
if (validationError) {
|
||||||
|
setError(validationError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await register(form, setToken);
|
||||||
|
router.push("/tabs/home");
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error:", error.response || error.message);
|
||||||
|
if (error.response?.detail) {
|
||||||
|
const decodedError = handleError({ detail: error.response.detail });
|
||||||
|
setError(decodedError);
|
||||||
|
} else {
|
||||||
|
setError(error.message || "An unexpected error occurred.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackgroundWrapper>
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center px-4 py-10">
|
||||||
|
<div className="w-full max-w-md space-y-6">
|
||||||
|
<div className="w-full aspect-[368/89] mx-auto">
|
||||||
|
<Image
|
||||||
|
src="/images/logo/logo.png"
|
||||||
|
alt="Logo"
|
||||||
|
width={368}
|
||||||
|
height={89}
|
||||||
|
className="w-full h-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-10">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<FormField
|
||||||
|
title="Full name"
|
||||||
|
value={form.name}
|
||||||
|
handleChangeText={(e) =>
|
||||||
|
setForm({ ...form, name: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
title="Institution"
|
||||||
|
placeholder="Enter a institution"
|
||||||
|
value={form.institution}
|
||||||
|
handleChangeText={(e) =>
|
||||||
|
setForm({ ...form, institution: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
title="SSC Roll No."
|
||||||
|
placeholder="Enter your SSC Roll No."
|
||||||
|
value={form.sscRoll}
|
||||||
|
handleChangeText={(e) =>
|
||||||
|
setForm({ ...form, sscRoll: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
title="HSC Roll No."
|
||||||
|
placeholder="Enter your HSC Roll No."
|
||||||
|
value={form.hscRoll}
|
||||||
|
handleChangeText={(e) =>
|
||||||
|
setForm({ ...form, hscRoll: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
title="Email Address"
|
||||||
|
placeholder="Enter your email address..."
|
||||||
|
value={form.email}
|
||||||
|
handleChangeText={(e) =>
|
||||||
|
setForm({ ...form, email: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
title="Phone Number"
|
||||||
|
placeholder="Enter your phone number.."
|
||||||
|
value={form.phone}
|
||||||
|
handleChangeText={(e) =>
|
||||||
|
setForm({ ...form, phone: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
title="Password"
|
||||||
|
placeholder="Enter a password"
|
||||||
|
value={form.password}
|
||||||
|
handleChangeText={(e) =>
|
||||||
|
setForm({ ...form, password: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <DestructibleAlert text={error} />}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={createUser}
|
||||||
|
className="w-full h-14 rounded-full border border-blue-900 text-center text-base font-medium"
|
||||||
|
>
|
||||||
|
Get started
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-center text-sm">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link href="/login" className="text-blue-600">
|
||||||
|
Login here
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BackgroundWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
app/(tabs)/bookmark/page.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const page = () => {
|
||||||
|
return <div>page</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default page;
|
||||||
7
app/(tabs)/categories/page.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const page = () => {
|
||||||
|
return <div>page</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default page;
|
||||||
246
app/(tabs)/home/page.tsx
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
import SlidingGallery from "@/components/SlidingGallery";
|
||||||
|
import BackgroundWrapper from "@/components/BackgroundWrapper";
|
||||||
|
import DestructibleAlert from "@/components/DestructibleAlert";
|
||||||
|
import { ChevronRight } from "lucide-react"; // Using Lucide React for icons
|
||||||
|
import styles from "@/css/Home.module.css";
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000/api";
|
||||||
|
|
||||||
|
const page = () => {
|
||||||
|
const profileImg = "/images/static/avatar.jpg";
|
||||||
|
const router = useRouter();
|
||||||
|
const [boardData, setBoardData] = useState([]);
|
||||||
|
const [boardError, setBoardError] = useState(null);
|
||||||
|
|
||||||
|
const performanceData = [
|
||||||
|
{ label: "Mock Test", progress: 20 },
|
||||||
|
{ label: "Topic Test", progress: 70 },
|
||||||
|
{ label: "Subject Test", progress: 50 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const progressData = [
|
||||||
|
{ label: "Physics", progress: 25 },
|
||||||
|
{ label: "Chemistry", progress: 57 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Fetch function for leaderboard data
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
async function fetchBoardData() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/leaderboard`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch leaderboard data");
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
if (isMounted) setBoardData(data);
|
||||||
|
} catch (error) {
|
||||||
|
if (isMounted) setBoardError(error.message || "An error occurred");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchBoardData();
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getTopThree = (boardData) => {
|
||||||
|
if (!boardData || boardData.length === 0) return [];
|
||||||
|
return boardData
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.points - a.points)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((player, index) => ({
|
||||||
|
...player,
|
||||||
|
rank: index + 1,
|
||||||
|
height: index === 0 ? 250 : index === 1 ? 200 : 170,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackgroundWrapper>
|
||||||
|
<div className={styles.container}>
|
||||||
|
<Header displayTabTitle={null} displayUser image={profileImg} />
|
||||||
|
<div className={styles.scrollContainer}>
|
||||||
|
<div className={styles.contentWrapper}>
|
||||||
|
<SlidingGallery />
|
||||||
|
<div className={styles.mainContent}>
|
||||||
|
{/* Categories Section */}
|
||||||
|
<div>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<h2 className={styles.sectionTitle}>Categories</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/categories")}
|
||||||
|
className={styles.arrowButton}
|
||||||
|
>
|
||||||
|
<ChevronRight size={24} color="#113768" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.categoriesContainer}>
|
||||||
|
<div className={styles.categoryRow}>
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
className={`${styles.categoryButton} ${styles.disabled}`}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/images/icons/topic-test.png"
|
||||||
|
alt="Topic Test"
|
||||||
|
width={70}
|
||||||
|
height={70}
|
||||||
|
/>
|
||||||
|
<span className={styles.categoryButtonText}>
|
||||||
|
Topic Test
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/unit")}
|
||||||
|
className={styles.categoryButton}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/images/icons/mock-test.png"
|
||||||
|
alt="Mock Test"
|
||||||
|
width={70}
|
||||||
|
height={70}
|
||||||
|
/>
|
||||||
|
<span className={styles.categoryButtonText}>
|
||||||
|
Mock Test
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.categoryRow}>
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
className={`${styles.categoryButton} ${styles.disabled}`}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/images/icons/past-paper.png"
|
||||||
|
alt="Past Papers"
|
||||||
|
width={62}
|
||||||
|
height={62}
|
||||||
|
/>
|
||||||
|
<span className={styles.categoryButtonText}>
|
||||||
|
Past Papers
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
className={`${styles.categoryButton} ${styles.disabled}`}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/images/icons/subject-test.png"
|
||||||
|
alt="Subject Test"
|
||||||
|
width={70}
|
||||||
|
height={70}
|
||||||
|
/>
|
||||||
|
<span className={styles.categoryButtonText}>
|
||||||
|
Subject Test
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Leaderboard Section */}
|
||||||
|
<div className={styles.leaderboardWrapper}>
|
||||||
|
<h2 className={styles.sectionTitle}>Leaderboard</h2>
|
||||||
|
<div className={styles.leaderboardContainer}>
|
||||||
|
<div className={styles.topThreeHeader}>
|
||||||
|
<span className={styles.topThreeTitle}>Top 3</span>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/leaderboard")}
|
||||||
|
className={styles.arrowButton}
|
||||||
|
>
|
||||||
|
<ChevronRight size={24} color="#113768" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.divider}></div>
|
||||||
|
<div className={styles.topThreeList}>
|
||||||
|
{getTopThree(boardData).map((student, idx) => (
|
||||||
|
<div key={idx} className={styles.topThreeItem}>
|
||||||
|
<div className={styles.studentInfo}>
|
||||||
|
<span className={styles.rank}>{student.rank}</span>
|
||||||
|
<Image
|
||||||
|
src="/images/static/avatar.jpg"
|
||||||
|
alt="Avatar"
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
className={styles.avatar}
|
||||||
|
/>
|
||||||
|
<span className={styles.studentName}>
|
||||||
|
{student.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className={styles.points}>
|
||||||
|
{student.points}pt
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Performance Summary Section */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<h2 className={styles.sectionTitle}>Performance Summary</h2>
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
onClick={() => router.push("/performance")}
|
||||||
|
className={styles.arrowButton}
|
||||||
|
>
|
||||||
|
<ChevronRight size={24} color="#113768" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.comingSoonCard}>
|
||||||
|
<p className={styles.comingSoonText}>Coming soon.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Tracker Section */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<h2 className={styles.sectionTitle}>Progress Tracker</h2>
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
onClick={() => router.push("/progress")}
|
||||||
|
className={styles.arrowButton}
|
||||||
|
>
|
||||||
|
<ChevronRight size={24} color="#113768" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.comingSoonCard}>
|
||||||
|
<p className={styles.comingSoonText}>Coming soon.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Daily Quiz Section */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Daily Quiz</h2>
|
||||||
|
<div className={styles.comingSoonCard}>
|
||||||
|
<p className={styles.comingSoonText}>Coming soon.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live Exams Section */}
|
||||||
|
<div className={`${styles.section} ${styles.lastSection}`}>
|
||||||
|
<h2 className={styles.sectionTitle}>Live Exams</h2>
|
||||||
|
<div className={styles.comingSoonCard}>
|
||||||
|
<p className={styles.comingSoonText}>Coming soon.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BackgroundWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default page;
|
||||||
38
app/(tabs)/layout.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
// app/tabs/layout.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ name: "Home", href: "/tabs/home" },
|
||||||
|
{ name: "Profile", href: "/tabs/profile" },
|
||||||
|
{ name: "Leaderboard", href: "/tabs/leaderboard" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TabsLayout({ children }: { children: ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
<main className="flex-1">{children}</main>
|
||||||
|
|
||||||
|
<nav className="flex justify-around border-t p-4 bg-white">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<Link
|
||||||
|
key={tab.name}
|
||||||
|
href={tab.href}
|
||||||
|
className={clsx(
|
||||||
|
"text-center text-sm font-medium",
|
||||||
|
pathname === tab.href ? "text-blue-600" : "text-gray-500"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
0
app/(tabs)/leaderboard/page.tsx
Normal file
0
app/(tabs)/live/page.tsx
Normal file
7
app/(tabs)/performance/page.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const page = () => {
|
||||||
|
return <div>page</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default page;
|
||||||
7
app/(tabs)/profile/page.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const page = () => {
|
||||||
|
return <div>page</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default page;
|
||||||
7
app/(tabs)/progress/page.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const page = () => {
|
||||||
|
return <div>page</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default page;
|
||||||
63
app/(tabs)/unit/page.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Header from "@/components/Header";
|
||||||
|
import BackgroundWrapper from "@/components/BackgroundWrapper";
|
||||||
|
|
||||||
|
const units = [
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: "C Unit (Business Studies)",
|
||||||
|
rating: 9,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const Unit = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleUnitPress = (unit) => {
|
||||||
|
router.push(`/paper?name=${encodeURIComponent(unit.name)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackgroundWrapper>
|
||||||
|
<div className="flex flex-col min-h-screen">
|
||||||
|
<Header
|
||||||
|
displayExamInfo={null}
|
||||||
|
displayTabTitle={"Units"}
|
||||||
|
displaySubject={null}
|
||||||
|
displayUser={false}
|
||||||
|
title=""
|
||||||
|
image={""}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="overflow-y-auto">
|
||||||
|
<div className="border border-blue-200 gap-4 rounded-3xl p-6 mx-10 mt-10">
|
||||||
|
{units ? (
|
||||||
|
units.map((unit) => (
|
||||||
|
<button
|
||||||
|
key={unit.id}
|
||||||
|
onClick={() => handleUnitPress(unit)}
|
||||||
|
className="border-2 border-blue-300 py-4 rounded-xl px-6 gap-2 block w-full text-left hover:bg-blue-50 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<p className="text-lg font-medium">{unit.name}</p>
|
||||||
|
<p className="text-sm">Rating: {unit.rating} / 10</p>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mb-4"></div>
|
||||||
|
<p className="font-bold text-2xl text-center">Loading...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* <CustomBackHandler fallbackRoute="home" useCustomHandler={false} /> */}
|
||||||
|
</BackgroundWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Unit;
|
||||||
7
app/exam/[id]/page.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const page = () => {
|
||||||
|
return <div>page</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default page;
|
||||||
7
app/exam/pretest/page.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const page = () => {
|
||||||
|
return <div>page</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default page;
|
||||||
7
app/exam/results/page.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const page = () => {
|
||||||
|
return <div>page</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default page;
|
||||||
BIN
app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
5
app/globals.css
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: var(--font-montserrat), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
33
app/layout.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Montserrat } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
import { AuthProvider } from "@/context/AuthContext";
|
||||||
|
import { TimerProvider } from "@/context/TimerContext";
|
||||||
|
|
||||||
|
const montserrat = Montserrat({
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"],
|
||||||
|
variable: "--font-montserrat",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "ExamJam",
|
||||||
|
description: "Your exam preparation platform",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className={montserrat.variable}>
|
||||||
|
<body className="font-sans">
|
||||||
|
<TimerProvider>
|
||||||
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
</TimerProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
app/page.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import BackgroundWrapper from "@/components/BackgroundWrapper";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [windowDimensions, setWindowDimensions] = useState({
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleResize() {
|
||||||
|
setWindowDimensions({
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set initial dimensions
|
||||||
|
handleResize();
|
||||||
|
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogin = () => {
|
||||||
|
router.push("/login");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackgroundWrapper>
|
||||||
|
<div className="mx-10 h-screen">
|
||||||
|
<div className="h-full flex flex-col justify-around pt-10">
|
||||||
|
{/* Logo Container */}
|
||||||
|
<div className="w-full" style={{ aspectRatio: "368/89" }}>
|
||||||
|
<Image
|
||||||
|
src="/images/logo/logo.png"
|
||||||
|
alt="Logo"
|
||||||
|
width={368}
|
||||||
|
height={89}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login Graphic */}
|
||||||
|
<div className="w-full h-1/2">
|
||||||
|
<Image
|
||||||
|
src="/images/static/login-graphic-1.png"
|
||||||
|
alt="Login illustration"
|
||||||
|
width={400}
|
||||||
|
height={300}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<button
|
||||||
|
onClick={handleLogin}
|
||||||
|
className="w-full h-[60px] flex justify-center items-center border border-[#113768] rounded-full bg-transparent hover:bg-[#113768] hover:text-white transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-medium"
|
||||||
|
style={{ fontFamily: "Montserrat, sans-serif" }}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p
|
||||||
|
className="text-center font-medium"
|
||||||
|
style={{ fontFamily: "Montserrat, sans-serif" }}
|
||||||
|
>
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Link href="/register" className="text-[#276ac0] hover:underline">
|
||||||
|
Register here
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BackgroundWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
components/BackgroundWrapper.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const BackgroundWrapper = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="min-h-screen bg-cover bg-center bg-no-repeat relative"
|
||||||
|
style={{
|
||||||
|
backgroundImage: "url('/images/static/paper-background.png')",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="min-h-screen"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0)", // Optional overlay - adjust opacity as needed
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BackgroundWrapper;
|
||||||
38
components/DestructibleAlert.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const DestructibleAlert = ({
|
||||||
|
text,
|
||||||
|
extraStyles = "",
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
extraStyles?: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`border bg-red-200 border-blue-200 rounded-3xl py-6 ${extraStyles}`}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
backgroundColor: "#fecaca",
|
||||||
|
borderColor: "#c0dafc",
|
||||||
|
paddingTop: 24,
|
||||||
|
paddingBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="text-center text-red-800"
|
||||||
|
style={{
|
||||||
|
fontSize: 17,
|
||||||
|
lineHeight: "28px",
|
||||||
|
fontFamily: "Montserrat, sans-serif",
|
||||||
|
fontWeight: "bold",
|
||||||
|
textAlign: "center",
|
||||||
|
color: "#991b1b",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DestructibleAlert;
|
||||||
80
components/FormField.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
const FormField = ({
|
||||||
|
title,
|
||||||
|
placeholder,
|
||||||
|
value,
|
||||||
|
handleChangeText,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
const isPasswordField = title === "Password" || title === "Confirm Password";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<label
|
||||||
|
className="block mb-2"
|
||||||
|
style={{
|
||||||
|
color: "#666666",
|
||||||
|
fontFamily: "Montserrat, sans-serif",
|
||||||
|
fontWeight: "500",
|
||||||
|
fontSize: 18,
|
||||||
|
marginBottom: 8,
|
||||||
|
letterSpacing: "-0.5px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="h-16 px-4 bg-blue-200 rounded-3xl flex items-center justify-between"
|
||||||
|
style={{
|
||||||
|
height: 64,
|
||||||
|
paddingLeft: 16,
|
||||||
|
paddingRight: 16,
|
||||||
|
backgroundColor: "#D2DFF0",
|
||||||
|
borderRadius: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type={isPasswordField && !showPassword ? "password" : "text"}
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => handleChangeText(e.target.value)}
|
||||||
|
className="flex-1 bg-transparent outline-none border-none text-blue-950"
|
||||||
|
style={{
|
||||||
|
color: "#0D47A1",
|
||||||
|
fontSize: 16,
|
||||||
|
fontFamily: "inherit",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isPasswordField && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="ml-2 text-gray-600 hover:text-gray-800 focus:outline-none"
|
||||||
|
style={{
|
||||||
|
fontFamily: "Montserrat, sans-serif",
|
||||||
|
fontWeight: "500",
|
||||||
|
fontSize: 16,
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showPassword ? "Hide" : "Show"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FormField;
|
||||||
169
components/Header.tsx
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { ChevronLeft, Layers } from "lucide-react";
|
||||||
|
import { useTimer } from "@/context/TimerContext";
|
||||||
|
import styles from "@/css/Header.module.css";
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000/api";
|
||||||
|
|
||||||
|
// You'll need to implement getToken for Next.js - could use cookies, localStorage, etc.
|
||||||
|
const getToken = async () => {
|
||||||
|
// Replace with your token retrieval logic
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
return localStorage.getItem("token") || sessionStorage.getItem("token");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Header = ({
|
||||||
|
image,
|
||||||
|
displayUser,
|
||||||
|
displaySubject,
|
||||||
|
displayTabTitle,
|
||||||
|
examDuration,
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [totalSeconds, setTotalSeconds] = useState(
|
||||||
|
examDuration ? parseInt(examDuration) * 60 : 0
|
||||||
|
);
|
||||||
|
const { timeRemaining, stopTimer } = useTimer();
|
||||||
|
const [userData, setUserData] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!examDuration) return;
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setTotalSeconds((prev) => {
|
||||||
|
if (prev <= 0) {
|
||||||
|
clearInterval(timer);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [examDuration]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchUser() {
|
||||||
|
try {
|
||||||
|
const token = await getToken();
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const response = await fetch(`${API_URL}/me`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const fetchedUserData = await response.json();
|
||||||
|
setUserData(fetchedUserData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching user data:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayUser) {
|
||||||
|
fetchUser();
|
||||||
|
}
|
||||||
|
}, [displayUser]);
|
||||||
|
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
const showExitDialog = () => {
|
||||||
|
const confirmed = window.confirm("Are you sure you want to quit the exam?");
|
||||||
|
|
||||||
|
if (confirmed) {
|
||||||
|
if (stopTimer) {
|
||||||
|
stopTimer();
|
||||||
|
}
|
||||||
|
router.push("/unit");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackClick = () => {
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={styles.header}>
|
||||||
|
{displayUser && (
|
||||||
|
<div className={styles.profile}>
|
||||||
|
{image && (
|
||||||
|
<Image
|
||||||
|
src={image}
|
||||||
|
alt="Profile"
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
|
className={styles.profileImg}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className={styles.text}>
|
||||||
|
Hello {userData?.name ? userData.name.split(" ")[0] : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displaySubject && (
|
||||||
|
<div className={styles.profile}>
|
||||||
|
<button onClick={handleBackClick} className={styles.iconButton}>
|
||||||
|
<ChevronLeft size={24} color="white" />
|
||||||
|
</button>
|
||||||
|
<span className={styles.text}>{displaySubject}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayTabTitle && (
|
||||||
|
<div className={styles.profile}>
|
||||||
|
<span className={styles.text}>{displayTabTitle}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{examDuration && (
|
||||||
|
<div className={styles.examHeader}>
|
||||||
|
<button onClick={showExitDialog} className={styles.iconButton}>
|
||||||
|
<ChevronLeft size={30} color="white" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={styles.timer}>
|
||||||
|
<div className={styles.timeUnit}>
|
||||||
|
<span className={styles.timeValue}>
|
||||||
|
{String(hours).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
<span className={styles.timeLabel}>Hrs</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.timeUnit}>
|
||||||
|
<span className={styles.timeValue}>
|
||||||
|
{String(minutes).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
<span className={styles.timeLabel}>Mins</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.timeUnit}>
|
||||||
|
<span className={styles.timeValue}>
|
||||||
|
{String(seconds).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
<span className={styles.timeLabel}>Secs</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
onClick={() => router.push("/exam/modal")}
|
||||||
|
className={`${styles.iconButton} ${styles.disabled}`}
|
||||||
|
>
|
||||||
|
<Layers size={30} color="white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
||||||
71
components/SlidingGallery.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import styles from "../css/SlidingGallery.module.css";
|
||||||
|
|
||||||
|
const views = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
content: (
|
||||||
|
<Link
|
||||||
|
href="https://www.facebook.com/share/g/15jdqESvWV/?mibextid=wwXIfr"
|
||||||
|
className={styles.link}
|
||||||
|
>
|
||||||
|
<div className={styles.facebook}>
|
||||||
|
<div className={styles.textView}>
|
||||||
|
<h3 className={styles.facebookOne}>Meet, Share, and Learn!</h3>
|
||||||
|
<p className={styles.facebookTwo}>Join Facebook Community</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.logoView}>
|
||||||
|
<Image
|
||||||
|
src="/images/static/facebook-logo.png"
|
||||||
|
alt="Facebook Logo"
|
||||||
|
width={120}
|
||||||
|
height={120}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const SlidingGallery = () => {
|
||||||
|
const [activeIdx, setActiveIdx] = useState(0);
|
||||||
|
const scrollRef = useRef(null);
|
||||||
|
|
||||||
|
const handleScroll = (event) => {
|
||||||
|
const scrollLeft = event.target.scrollLeft;
|
||||||
|
const slideWidth = event.target.clientWidth;
|
||||||
|
const index = Math.round(scrollLeft / slideWidth);
|
||||||
|
setActiveIdx(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.gallery}>
|
||||||
|
<div
|
||||||
|
className={styles.scrollContainer}
|
||||||
|
ref={scrollRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
>
|
||||||
|
{views.map((item) => (
|
||||||
|
<div key={item.id} className={styles.slide}>
|
||||||
|
{item.content}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.pagination}>
|
||||||
|
{views.map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`${styles.dot} ${
|
||||||
|
activeIdx === index ? styles.activeDot : styles.inactiveDot
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SlidingGallery;
|
||||||
109
context/AuthContext.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
token: string | null;
|
||||||
|
setToken: (token: string | null) => void;
|
||||||
|
logout: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
// Cookie utility functions
|
||||||
|
const getCookie = (name: string): string | null => {
|
||||||
|
if (typeof document === "undefined") return null;
|
||||||
|
|
||||||
|
const value = `; ${document.cookie}`;
|
||||||
|
const parts = value.split(`; ${name}=`);
|
||||||
|
if (parts.length === 2) {
|
||||||
|
return parts.pop()?.split(";").shift() || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCookie = (
|
||||||
|
name: string,
|
||||||
|
value: string | null,
|
||||||
|
days: number = 7
|
||||||
|
): void => {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
// Delete cookie by setting expiration to past date
|
||||||
|
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Strict; Secure`;
|
||||||
|
} else {
|
||||||
|
const expires = new Date();
|
||||||
|
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
|
||||||
|
document.cookie = `${name}=${value}; expires=${expires.toUTCString()}; path=/; SameSite=Strict; Secure`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const [token, setTokenState] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Custom setToken function that also updates cookies
|
||||||
|
const setToken = (newToken: string | null) => {
|
||||||
|
setTokenState(newToken);
|
||||||
|
setCookie("authToken", newToken);
|
||||||
|
};
|
||||||
|
|
||||||
|
// On app load, check if there's a token in cookies
|
||||||
|
useEffect(() => {
|
||||||
|
const initializeAuth = () => {
|
||||||
|
const storedToken = getCookie("authToken");
|
||||||
|
|
||||||
|
if (storedToken) {
|
||||||
|
setTokenState(storedToken);
|
||||||
|
// Only redirect if we're on login/register pages
|
||||||
|
if (
|
||||||
|
router.pathname === "/" ||
|
||||||
|
router.pathname === "/login" ||
|
||||||
|
router.pathname === "/register"
|
||||||
|
) {
|
||||||
|
router.replace("/home");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Only redirect to login if we're on a protected page
|
||||||
|
const publicPages = ["/", "/login", "/register"];
|
||||||
|
if (!publicPages.includes(router.pathname)) {
|
||||||
|
router.replace("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Small delay to ensure router is ready
|
||||||
|
const timer = setTimeout(initializeAuth, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [router.pathname]);
|
||||||
|
|
||||||
|
// Function to log out
|
||||||
|
const logout = () => {
|
||||||
|
setTokenState(null);
|
||||||
|
setCookie("authToken", null); // Remove token from cookies
|
||||||
|
router.replace("/login"); // Redirect to login screen
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ token, setToken, logout, isLoading }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook to use the AuthContext
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useAuth must be used within an AuthProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
70
context/TimerContext.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||||
|
|
||||||
|
// Define the context type
|
||||||
|
interface TimerContextType {
|
||||||
|
timeRemaining: number;
|
||||||
|
resetTimer: (duration: number) => void;
|
||||||
|
stopTimer: () => void;
|
||||||
|
setInitialTime: (duration: number) => void; // New function to set the initial time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the context with a default value of `undefined`
|
||||||
|
const TimerContext = createContext<TimerContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
// Provider Component
|
||||||
|
export const TimerProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const [timeRemaining, setTimeRemaining] = useState<number>(0); // Default is 0
|
||||||
|
let timer: NodeJS.Timeout;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timeRemaining > 0) {
|
||||||
|
timer = setInterval(() => {
|
||||||
|
setTimeRemaining((prev) => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
clearInterval(timer);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(timer); // Cleanup timer on unmount
|
||||||
|
};
|
||||||
|
}, [timeRemaining]);
|
||||||
|
|
||||||
|
const resetTimer = (duration: number) => {
|
||||||
|
clearInterval(timer);
|
||||||
|
setTimeRemaining(duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopTimer = () => {
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setInitialTime = (duration: number) => {
|
||||||
|
setTimeRemaining(duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TimerContext.Provider
|
||||||
|
value={{ timeRemaining, resetTimer, stopTimer, setInitialTime }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TimerContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook to use the TimerContext
|
||||||
|
export const useTimer = (): TimerContextType => {
|
||||||
|
const context = useContext(TimerContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useTimer must be used within a TimerProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
179
css/Header.module.css
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
.header {
|
||||||
|
background-color: #113768;
|
||||||
|
height: 130px;
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 30px;
|
||||||
|
padding-left: 30px;
|
||||||
|
padding-right: 30px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profileImg {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
font-size: 24px;
|
||||||
|
font-family: 'Montserrat', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconButton {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconButton:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconButton.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.examHeader {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer {
|
||||||
|
width: 167px;
|
||||||
|
height: 55px;
|
||||||
|
background-color: #fff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeUnit {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeValue {
|
||||||
|
font-family: 'Montserrat', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #082E5E;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeLabel {
|
||||||
|
font-family: 'Montserrat', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #082E5E;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header {
|
||||||
|
height: 100px;
|
||||||
|
padding-top: 20px;
|
||||||
|
padding-left: 20px;
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile {
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profileImg {
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer {
|
||||||
|
width: 140px;
|
||||||
|
height: 45px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeValue {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.header {
|
||||||
|
height: 80px;
|
||||||
|
padding-top: 15px;
|
||||||
|
padding-left: 15px;
|
||||||
|
padding-right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profileImg {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer {
|
||||||
|
width: 120px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeValue {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeLabel {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.examHeader {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Safe area considerations for mobile */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.header {
|
||||||
|
padding-top: max(15px, env(safe-area-inset-top));
|
||||||
|
}
|
||||||
|
}
|
||||||
267
css/Home.module.css
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
.container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollContainer {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-top: 40px;
|
||||||
|
height: calc(100vh - 80px); /* Adjust based on header height */
|
||||||
|
}
|
||||||
|
|
||||||
|
.contentWrapper {
|
||||||
|
margin: 0 35px;
|
||||||
|
padding-bottom: 80px; /* Extra space at bottom for mobile */
|
||||||
|
}
|
||||||
|
|
||||||
|
.mainContent {
|
||||||
|
padding-top: 25px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lastSection {
|
||||||
|
margin-bottom: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionHeader {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 32px;
|
||||||
|
font-family: 'Montserrat', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #113768;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrowButton {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrowButton:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrowButton:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Categories Styles */
|
||||||
|
.categoriesContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding-top: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryRow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryButton {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid #c5dbf8;
|
||||||
|
width: 48%;
|
||||||
|
height: 150px; /* Approximate scaled height */
|
||||||
|
border-radius: 25px;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryButton:hover:not(:disabled) {
|
||||||
|
background-color: #f8fbff;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryButton.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryButtonText {
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: 'Montserrat', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #113768;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Leaderboard Styles */
|
||||||
|
.leaderboardWrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboardContainer {
|
||||||
|
border: 1px solid #c5dbf8;
|
||||||
|
padding: 22px 15px;
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topThreeHeader {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topThreeTitle {
|
||||||
|
font-family: 'Montserrat', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
border-top: 0.5px solid #c5dbf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topThreeList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topThreeItem {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
border: 1px solid #c5dbf8;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.studentInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank {
|
||||||
|
font-family: 'Montserrat', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.studentName {
|
||||||
|
font-family: 'Montserrat', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.points {
|
||||||
|
font-family: 'Montserrat', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Coming Soon Card */
|
||||||
|
.comingSoonCard {
|
||||||
|
border: 2px solid #c5dbf8;
|
||||||
|
width: 100%;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comingSoonText {
|
||||||
|
font-family: 'Montserrat', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 20px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.contentWrapper {
|
||||||
|
margin: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryButton {
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryButtonText {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryRow {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comingSoonText {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.contentWrapper {
|
||||||
|
margin: 0 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryButton {
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mainContent {
|
||||||
|
gap: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryRow {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categoryButton {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
119
css/SlidingGallery.module.css
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
.gallery {
|
||||||
|
height: 200px;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #113768;
|
||||||
|
border-radius: 25px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollContainer {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
-ms-overflow-style: none; /* Internet Explorer 10+ */
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollContainer::-webkit-scrollbar {
|
||||||
|
display: none; /* WebKit */
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide {
|
||||||
|
min-width: calc(100% - 72px);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
padding: 0 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facebook {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-direction: row;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facebookOne {
|
||||||
|
font-family: 'Montserrat', sans-serif;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #113768;
|
||||||
|
font-size: 20px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facebookTwo {
|
||||||
|
font-family: 'Montserrat', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #113768;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activeDot {
|
||||||
|
background-color: #113768;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inactiveDot {
|
||||||
|
background-color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textView {
|
||||||
|
width: 60%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoView {
|
||||||
|
width: 40%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.slide {
|
||||||
|
min-width: calc(100% - 40px);
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facebookOne {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.facebookTwo {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
eslint.config.mjs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eslintConfig = [
|
||||||
|
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
90
lib/auth.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
export const API_URL = "https://examjam-api.pptx704.com";
|
||||||
|
|
||||||
|
// Cookie utility functions
|
||||||
|
const setCookie = (name, value, days = 7) => {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
// Delete cookie by setting expiration to past date
|
||||||
|
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Strict; Secure`;
|
||||||
|
} else {
|
||||||
|
const expires = new Date();
|
||||||
|
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
|
||||||
|
document.cookie = `${name}=${value}; expires=${expires.toUTCString()}; path=/; SameSite=Strict; Secure`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const login = async (form, setToken) => {
|
||||||
|
const response = await fetch(`${API_URL}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || "Login failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the token to cookies instead of secure storage
|
||||||
|
setCookie("authToken", data.token);
|
||||||
|
setToken(data.token); // Update the token in context
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = (error) => {
|
||||||
|
// Check if error has a "detail" property
|
||||||
|
if (error?.detail) {
|
||||||
|
// Match the field causing the issue
|
||||||
|
const match = error.detail.match(/Key \((.*?)\)=\((.*?)\)/);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const field = match[1]; // The field name, e.g., "phone"
|
||||||
|
const value = match[2]; // The duplicate value, e.g., "0987654321"
|
||||||
|
return `The ${field} already exists. Please use a different value.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "An unexpected error occurred. Please try again.";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const register = async (form, setToken) => {
|
||||||
|
const response = await fetch(`${API_URL}/auth/register`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(form),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json(); // Parse the response JSON
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Instead of throwing a string, include full error data for debugging
|
||||||
|
const error = new Error(data?.detail || "Registration failed");
|
||||||
|
error.response = data; // Attach the full response for later use
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the token to cookies instead of secure storage
|
||||||
|
setCookie("authToken", data.token);
|
||||||
|
setToken(data.token); // Update the token in context
|
||||||
|
};
|
||||||
|
|
||||||
|
// Additional utility function to get token from cookies (if needed elsewhere)
|
||||||
|
export const getTokenFromCookie = () => {
|
||||||
|
if (typeof document === "undefined") return null;
|
||||||
|
|
||||||
|
const value = `; ${document.cookie}`;
|
||||||
|
const parts = value.split(`; authToken=`);
|
||||||
|
if (parts.length === 2) {
|
||||||
|
return parts.pop()?.split(";").shift() || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utility function to clear auth token (for logout)
|
||||||
|
export const clearAuthToken = () => {
|
||||||
|
setCookie("authToken", null);
|
||||||
|
};
|
||||||
7
next.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
4599
package-lock.json
generated
Normal file
32
package.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "examjam-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --turbopack",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"capacitor-secure-storage-plugin": "^0.11.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.523.0",
|
||||||
|
"next": "15.3.2",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3",
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "15.3.2",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
postcss.config.mjs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: ["@tailwindcss/postcss"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
BIN
public/images/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/images/icons/mock-test.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/images/icons/past-paper.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/images/icons/subject-test.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/images/icons/topic-test.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/images/logo/logo.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
public/images/static/avatar.jpg
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
public/images/static/facebook-logo.png
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
public/images/static/login-graphic-1.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
public/images/static/paper-background.png
Normal file
|
After Width: | Height: | Size: 192 KiB |
27
tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||