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"]
|
||||
}
|
||||