diff --git a/app/add-project/page.tsx b/app/add-project/page.tsx index 54417eb..b725303 100644 --- a/app/add-project/page.tsx +++ b/app/add-project/page.tsx @@ -1,111 +1,80 @@ "use client"; -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { ArrowLeft, Save, XCircle } from "lucide-react"; import Link from "next/link"; -const submissionYears = [2025, 2024, 2023, 2022, 2021]; -const projectTypes = [ - "Final Year Project", - "Mini Project", - "Research Project", - "Personal Project", - "Others" -]; -const departments = ["CSE", "IT", "ECE", "EEE", "MECH", "CIVIL", "Other"]; -const availableDomains = [ - "Other", - "Web Development", - "Mobile App Development (Android & iOS)", - "Artificial Intelligence (AI) & Machine Learning (ML)", - "Data Science & Big Data Analytics", - "Cybersecurity & Ethical Hacking", - "Blockchain & Cryptocurrency", - "Cloud Computing & DevOps", - "Game Development & AR/VR", - "Internet of Things (IoT)", - "Natural Language Processing (NLP)", - "Database Management & Data Warehousing", - "Quantum Computing", - "Software Testing & Automation", - "Full Stack Development (MERN, MEAN, etc.)", - "UI/UX & Human-Computer Interaction", - "Computer Networks & Network Security", - "Augmented Reality (AR) & Virtual Reality (VR)", - "E-commerce & CMS Development", - "No-Code & Low-Code Development", - "Cloud Security & Serverless Computing", - "DevOps & Site Reliability Engineering (SRE)", - "Edge Computing & Distributed Systems", - "IT Infrastructure & System Administration", - "Data Engineering & Business Intelligence", - "IT Governance & Compliance", - "Structural Engineering & Earthquake-Resistant Design", - "Transportation & Highway Engineering", - "Geotechnical Engineering & Soil Mechanics", - "Smart Cities & Urban Planning", - "Sustainable & Green Building Technology", - "Hydraulics & Water Resource Engineering", - "Construction Management & Project Planning", - "Environmental Engineering & Waste Management", - "Building Information Modeling (BIM)", - "Disaster Management & Risk Analysis", - "Bridge & Tunnel Engineering", - "Surveying & Remote Sensing (GIS & GPS)", - "VLSI & Chip Design", - "Embedded Systems & Microcontrollers", - "Wireless Communication (5G, LTE, Satellite)", - "Signal & Image Processing", - "Optical Fiber & Photonics", - "Digital & Analog Circuit Design", - "Antenna & RF Engineering", - "Smart Sensors & Wearable Technology", - "Audio & Speech Processing", - "Biomedical Electronics & Bionics", - "MEMS & Nanoelectronics", - "Power Systems & Smart Grids", - "Renewable Energy (Solar, Wind, Hydro)", - "Control Systems & Automation", - "Robotics & Mechatronics", - "Electric Vehicles (EV) & Battery Technologies", - "High Voltage Engineering", - "Energy Management & Conservation", - "Industrial Instrumentation & Process Control", - "Electrical Machines & Drives", - "Smart Home & Building Automation", - "CAD, CAM & 3D Printing", - "Automotive & Aerospace Engineering", - "Thermodynamics & Fluid Mechanics", - "Mechatronics & Smart Manufacturing", - "HVAC & Refrigeration Systems", - "Material Science & Composites", - "Renewable Energy in Mechanical Systems", - "Computational Fluid Dynamics (CFD)", - "Finite Element Analysis (FEA)" -]; +interface CategoryOption { // Define interfaces for fetched data + optionId: number; + optionName: string; +} + +interface Category { + categoryId: number; + categoryName: string; + options: CategoryOption[]; +} const AddProjectPage = () => { + const [categories, setCategories] = useState([]); + const [loadingCategories, setLoadingCategories] = useState(true); + const [errorCategories, setErrorCategories] = useState(null); + + useEffect(() => { + const fetchCategories = async () => { + try { + const res = await fetch('/api/categories'); + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + const data: Category[] = await res.json(); + setCategories(data); + } catch (e: any) { + setErrorCategories(e.message); + console.error("Failed to fetch categories for form:", e); + } finally { + setLoadingCategories(false); + } + }; + fetchCategories(); + }, []); + const initialFormState = { projectName: "", projectDescription: "", - yearOfSubmission: "2025", - projectType: "Personal Project", - department: "", - domain: "Web Development", - customDomain: "", projectLink: "", - members: [{ name: "", linkedin: "" }] + createdAt: "", + members: [{ name: "", linkedin: "" }], + selectedCategoryOptions: {} as Record, // Map category name to selected option name + customDomain: "", // Keep customDomain separate if 'Domain' is 'Other' }; const [formData, setFormData] = useState(initialFormState); - const [showPopup, setShowPopup] = useState(false); // State for pop-up visibility - const [loading, setLoading] = useState(false); // Loading state to prevent duplicate submissions + const [showPopup, setShowPopup] = useState(false); + const [loading, setLoading] = useState(false); + + useEffect(() => { + // Set initial default values for dropdowns after categories are fetched + if (!loadingCategories && categories.length > 0) { + setFormData(prev => { + const newSelectedOptions: Record = {}; + categories.forEach(cat => { + if (cat.options.length > 0) { + newSelectedOptions[cat.categoryName] = cat.options[0].optionName; + } + }); + return { + ...prev, + selectedCategoryOptions: newSelectedOptions, + }; + }); + } + }, [loadingCategories, categories]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (loading) return; // Prevent multiple submissions + if (loading) return; - // Ensure at least one member has a name const hasValidMember = formData.members.some( (member) => member.name.trim() !== "" ); @@ -114,37 +83,47 @@ const AddProjectPage = () => { return; } - setLoading(true); // Begin submission + setLoading(true); - // Filter out empty members const filteredMembers = formData.members.filter( (member) => member.name.trim() !== "" ); + // Prepare category options for backend + const projectCategoryOptions: { categoryName: string; optionName: string }[] = Object.entries(formData.selectedCategoryOptions).map(([categoryName, optionName]) => ({ + categoryName, + optionName: categoryName === 'Domain' && optionName === 'Other' ? formData.customDomain : optionName // Use customDomain if 'Other' domain is selected + })); + const projectData = { - ...formData, + projectName: formData.projectName, + projectDescription: formData.projectDescription, + projectLink: formData.projectLink, + createdAt: new Date().toISOString(), members: filteredMembers, - createdAt: new Date().toISOString() + projectCategoryOptions, // Send as a generic array of category options + customDomain: formData.selectedCategoryOptions['Domain'] === 'Other' ? formData.customDomain : undefined, // Send customDomain separately }; try { - const response = await fetch("/api/saveProject", { + const response = await fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(projectData) }); if (response.ok) { - setFormData(initialFormState); - setShowPopup(true); // Show the congratulatory pop-up - setTimeout(() => setShowPopup(false), 3000); // Hide it after 3 seconds + localStorage.removeItem('cachedProjects'); // Invalidate projects cache + setFormData(initialFormState); // Reset form + setShowPopup(true); + setTimeout(() => setShowPopup(false), 3000); } else { alert("Failed to save project."); } } catch (error) { console.error("Error saving project:", error); } finally { - setLoading(false); // End submission + setLoading(false); } }; @@ -154,7 +133,18 @@ const AddProjectPage = () => { > ) => { const { name, value } = e.target; - setFormData((prev) => ({ ...prev, [name]: value })); + if (name.startsWith("category-")) { + const categoryName = name.replace("category-", ""); + setFormData((prev) => ({ + ...prev, + selectedCategoryOptions: { + ...prev.selectedCategoryOptions, + [categoryName]: value, + }, + })); + } else { + setFormData((prev) => ({ ...prev, [name]: value })); + } }; const handleMemberChange = (index: number, field: string, value: string) => { @@ -179,6 +169,14 @@ const AddProjectPage = () => { setFormData(initialFormState); }; + if (loadingCategories) { + return
Loading form data...
; + } + + if (errorCategories) { + return
Error loading form data: {errorCategories}
; + } + return (
@@ -234,68 +232,40 @@ const AddProjectPage = () => { onChange={handleChange} value={formData.projectDescription} /> - - - - - {formData.domain === "Other" && ( - - )} + + {/* Dynamic Category Selects */} + {categories.length > 0 && categories.map(category => ( +
+ + + {category.categoryName === "Domain" && formData.selectedCategoryOptions["Domain"] === "Other" && ( + + )} +
+ ))} + = ({ children }) => { + const [loading, setLoading] = useState(true); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [userRole, setUserRole] = useState(null); + const router = useRouter(); + + useEffect(() => { + const checkAuth = async () => { + const unsubscribe = onAuthStateChanged(async (authUser) => { + if (authUser) { + const userDocRef = doc(firestore, 'adminemail', authUser.email as string); + const userDoc = await getDoc(userDocRef); + + if (userDoc.exists()) { + const role = userDoc.data()?.role; + if (role === 'admin' || role === 'superadmin') { + setIsAuthenticated(true); + setUserRole(role); + } else { + setIsAuthenticated(false); + router.push('/auth/Login'); + } + } else { + setIsAuthenticated(false); + router.push('/auth/Login'); + } + } else { + setIsAuthenticated(false); + router.push('/auth/Login'); + } + setLoading(false); + }); + + return () => unsubscribe(); + }; + + checkAuth(); + }, [router]); + + if (loading) { + return
Loading...
; + } + + if (!isAuthenticated) { + return null; + } + + return ( +
+
+
+
+ {children} +
+
+
+
+ ); +}; + +export default AdminLayout; \ No newline at end of file diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..e8ae993 --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useEffect, useState } from 'react'; +import Navbar from '@/components/Navbar'; +import ProjectGrid from '@/components/repeto/ProjectGrid'; +import FilterSection from '@/components/repeto/FilterSection'; +import TabSection from '@/components/repeto/TabSection'; +import LoadingScreen from "@/components/loadingScrenn"; +import Link from 'next/link'; + +export default function AdminDashboard() { + const [activeTab, setActiveTab] = useState("All"); + const [filters, setFilters] = useState>({}); + const [loading, setLoading] = useState(true); + const [refreshTrigger, setRefreshTrigger] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => setLoading(false), 1500); + setRefreshTrigger(prev => !prev); + return () => clearTimeout(timer); + }, []); + + const handleClearFilters = () => { + setFilters({}); + }; + + if (loading) { + return ; + } + + return ( +
+ +
+ +
+
+ + {/* Wrapper div to push buttons to the right */} +
+ + Manage Projects + + + Manage Categories + +
+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/app/admin/super-admin/page.tsx b/app/admin/super-admin/page.tsx new file mode 100644 index 0000000..6771f7b --- /dev/null +++ b/app/admin/super-admin/page.tsx @@ -0,0 +1,75 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { firestore, auth } from "@/lib/firebase/config"; +import { + collection, + doc, + getDoc, + setDoc, + deleteDoc, + getDocs, + Timestamp, +} from "firebase/firestore"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { AddAdmin, AdminList, RemoveAdmin, RemovedAdminsList } from "@/components/admin-components"; + +export default function AdminPanel() { + const [userRole, setUserRole] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchRole = async () => { + const user = auth.currentUser; + if (!user?.email) { + setUserRole(null); + setLoading(false); + return; + } + + const docRef = doc(firestore, "adminemail", user.email); + const docSnap = await getDoc(docRef); + + if (docSnap.exists()) { + setUserRole(docSnap.data().role); + } else { + setUserRole(null); + } + setLoading(false); + }; + + fetchRole(); + }, []); + + if (loading) return
Loading...
; + + if (userRole !== "superadmin") { + return ( +
+ You are not authorized to access this page. +
+ ); + } + + return ( +
+

Admin Panel

+ + + Add Admin + List Admins + Remove Admin + + + + + + + + + + + +
+ ); +} \ No newline at end of file diff --git a/app/api/categories/route.ts b/app/api/categories/route.ts new file mode 100644 index 0000000..b72281c --- /dev/null +++ b/app/api/categories/route.ts @@ -0,0 +1,144 @@ +import { NextResponse, NextRequest } from "next/server"; +import { db } from "@/lib/db"; +import { categories, categoryOptionValues, projectOptions } from "@/lib/schema"; +import { sql, eq } from "drizzle-orm"; + +export async function GET() { + try { + const allCategories = await db.select().from(categories).execute(); + + const results = await Promise.all(allCategories.map(async (cat) => { + let options: { optionId: number | null; optionName: string | null; }[] = []; + options = await db.select({ optionId: categoryOptionValues.optionId, optionName: categoryOptionValues.optionName }) + .from(categoryOptionValues) + .where(eq(categoryOptionValues.categoryId, cat.categoryId!)) + .execute(); + return { categoryId: cat.categoryId, categoryName: cat.category, options: options.filter(o => o.optionName !== null) }; + })); + + return NextResponse.json(results); + } catch (error) { + console.error("Error fetching categories:", error); + return NextResponse.json({ message: "Failed to fetch categories", error: error instanceof Error ? error.message : String(error) }, { status: 500 }); + } +} + +export async function POST(req: NextRequest) { + try { + const { categoryName, options } = await req.json(); + + if (!categoryName) { + return NextResponse.json({ message: "Category name is required." }, { status: 400 }); + } + + const [newCategory] = await db.insert(categories).values({ category: categoryName }).returning({ categoryId: categories.categoryId }); + + if (options && options.length > 0 && newCategory && newCategory.categoryId) { + for (const option of options) { + if (option.optionName) { + await db.insert(categoryOptionValues).values({ + optionName: option.optionName, + categoryId: newCategory.categoryId + }).execute(); + } + } + } + + return NextResponse.json(newCategory, { status: 201 }); + } catch (error) { + console.error("Error adding category:", error); + return NextResponse.json({ message: "Failed to add category.", error: error instanceof Error ? error.message : String(error) }, { status: 500 }); + } +} + +export async function PUT(req: NextRequest) { + try { + const { categoryId, categoryName, options } = await req.json(); + + console.log('PUT /api/categories - Incoming data:', { categoryId, categoryName, options }); + + if (!categoryId || !categoryName) { + console.error('Validation Error: Category ID or name missing for update.', { categoryId, categoryName }); + return NextResponse.json({ message: "Category ID and name are required for update." }, { status: 400 }); + } + + const [updatedCategory] = await db.update(categories) + .set({ category: categoryName }) + .where(eq(categories.categoryId, categoryId)) + .returning(); + + console.log('PUT /api/categories - Updated category result:', updatedCategory); + + if (!updatedCategory) { + console.error('Category Not Found: No category found with ID:', categoryId); + return NextResponse.json({ message: "Category not found." }, { status: 404 }); + } + + const optionsToProcess = Array.isArray(options) ? options : []; + + if (optionsToProcess.length > 0) { + console.log('PUT /api/categories - Deleting existing project options for categoryId:', categoryId); + // First, delete related entries from projectOptions to satisfy foreign key constraint + await db.delete(projectOptions).where(eq(projectOptions.categoryId, categoryId)); + console.log('PUT /api/categories - Existing project options deleted.'); + + console.log('PUT /api/categories - Deleting existing category options for categoryId:', categoryId); + await db.delete(categoryOptionValues).where(eq(categoryOptionValues.categoryId, categoryId)); + console.log('PUT /api/categories - Existing category options deleted.'); + + const optionsToInsert = []; + for (const option of optionsToProcess) { + if (option.optionName) { // Only insert options with a name + optionsToInsert.push({ + optionName: option.optionName, + categoryId: categoryId + }); + } + } + + if (optionsToInsert.length > 0) { + console.log('PUT /api/categories - Inserting new options:', optionsToInsert); + await db.insert(categoryOptionValues).values(optionsToInsert).execute(); + console.log('PUT /api/categories - New options inserted.'); + } else { + console.log('PUT /api/categories - No valid options to insert.'); + } + } else if (optionsToProcess.length === 0 && options !== undefined) { // Check if options was explicitly an empty array + console.log('PUT /api/categories - Options array is empty, deleting all existing project options and category options.'); + // Delete related entries from projectOptions first + await db.delete(projectOptions).where(eq(projectOptions.categoryId, categoryId)); + await db.delete(categoryOptionValues).where(eq(categoryOptionValues.categoryId, categoryId)); + } else { + console.log('PUT /api/categories - No options provided or options is not an array. Skipping option updates.'); + } + + return NextResponse.json({ message: "Category updated successfully." }); + } catch (error) { + console.error("Error updating category:", error); + // Re-throw or return more specific error if possible + return NextResponse.json({ message: "Failed to update category.", error: error instanceof Error ? error.message : String(error) }, { status: 500 }); + } +} + +export async function DELETE(req: NextRequest) { + try { + const { categoryId } = await req.json(); + + if (!categoryId) { + return NextResponse.json({ message: "Category ID is required for deletion." }, { status: 400 }); + } + + await db.delete(categoryOptionValues).where(eq(categoryOptionValues.categoryId, categoryId)); + + const [deletedCategory] = await db.delete(categories).where(eq(categories.categoryId, categoryId)).returning(); + + if (!deletedCategory) { + return NextResponse.json({ message: "Category not found." }, { status: 404 }); + } + + return NextResponse.json({ message: "Category deleted successfully." }); + } catch (error) { + console.error("Error deleting category:", error); + return NextResponse.json({ message: "Failed to delete category.", error: error instanceof Error ? error.message : String(error) }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/category-options/route.ts b/app/api/category-options/route.ts new file mode 100644 index 0000000..eed8b38 --- /dev/null +++ b/app/api/category-options/route.ts @@ -0,0 +1,118 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '../../../lib/db'; +import { categories, categoryOptionValues } from '../../../lib/schema'; +import { eq, and } from 'drizzle-orm'; + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const categoryName = searchParams.get('categoryName'); + + if (!categoryName) { + return NextResponse.json({ message: 'Category name is required.' }, { status: 400 }); + } + + // Find the category by name to get its ID + const category = await db.select().from(categories).where(eq(categories.category, categoryName)).execute(); + if (category.length === 0 || !category[0].categoryId) { + return NextResponse.json({ message: 'Category not found.' }, { status: 404 }); + } + const categoryId = category[0].categoryId; + + // Fetch options from the generic categoryOptionValues table for this categoryId + const options = await db.select({ optionId: categoryOptionValues.optionId, optionName: categoryOptionValues.optionName }) + .from(categoryOptionValues) + .where(eq(categoryOptionValues.categoryId, categoryId)) + .execute(); + return NextResponse.json(options); + } catch (error) { + console.error('Error fetching category options:', error); + return NextResponse.json({ message: 'Failed to fetch category options.' }, { status: 500 }); + } +} + +export async function POST(req: NextRequest) { + try { + const { categoryName, optionName } = await req.json(); + + if (!categoryName || !optionName) { + return NextResponse.json({ message: 'Category name and option name are required.' }, { status: 400 }); + } + + // Find the category by name to get its ID + const category = await db.select().from(categories).where(eq(categories.category, categoryName)).execute(); + if (category.length === 0 || !category[0].categoryId) { + return NextResponse.json({ message: 'Category not found. Cannot add option.' }, { status: 404 }); + } + const categoryId = category[0].categoryId; + + // Insert new option into the generic categoryOptionValues table + const [newOption] = await db.insert(categoryOptionValues).values({ optionName, categoryId }).returning(); + return NextResponse.json(newOption, { status: 201 }); + } catch (error) { + console.error('Error adding category option:', error); + return NextResponse.json({ message: 'Failed to add category option.' }, { status: 500 }); + } +} + +export async function PUT(req: NextRequest) { + try { + const { categoryName, optionId, optionName } = await req.json(); + + if (!categoryName || !optionId || !optionName) { + return NextResponse.json({ message: 'Category name, option ID, and new option name are required.' }, { status: 400 }); + } + + // Find the category by name to get its ID (though optionId should be sufficient if unique across categories) + const category = await db.select().from(categories).where(eq(categories.category, categoryName)).execute(); + if (category.length === 0 || !category[0].categoryId) { + return NextResponse.json({ message: 'Category not found. Cannot update option.' }, { status: 404 }); + } + const categoryId = category[0].categoryId; + + // Update option in the generic categoryOptionValues table + const [updatedOption] = await db.update(categoryOptionValues).set({ optionName }) + .where(and(eq(categoryOptionValues.optionId, optionId), eq(categoryOptionValues.categoryId, categoryId))) + .returning(); + + if (!updatedOption) { + return NextResponse.json({ message: 'Option not found or does not belong to the specified category.' }, { status: 404 }); + } + + return NextResponse.json(updatedOption); + } catch (error) { + console.error('Error updating category option:', error); + return NextResponse.json({ message: 'Failed to update category option.' }, { status: 500 }); + } +} + +export async function DELETE(req: NextRequest) { + try { + const { categoryName, optionId } = await req.json(); + + if (!categoryName || !optionId) { + return NextResponse.json({ message: 'Category name and option ID are required.' }, { status: 400 }); + } + + // Find the category by name to get its ID + const category = await db.select().from(categories).where(eq(categories.category, categoryName)).execute(); + if (category.length === 0 || !category[0].categoryId) { + return NextResponse.json({ message: 'Category not found. Cannot delete option.' }, { status: 404 }); + } + const categoryId = category[0].categoryId; + + // Delete option from the generic categoryOptionValues table + const [deletedOption] = await db.delete(categoryOptionValues) + .where(and(eq(categoryOptionValues.optionId, optionId), eq(categoryOptionValues.categoryId, categoryId))) + .returning(); + + if (!deletedOption) { + return NextResponse.json({ message: 'Option not found or does not belong to the specified category.' }, { status: 404 }); + } + + return NextResponse.json({ message: 'Option deleted successfully.' }); + } catch (error) { + console.error('Error deleting category option:', error); + return NextResponse.json({ message: 'Failed to delete category option.' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts new file mode 100644 index 0000000..bd09e82 --- /dev/null +++ b/app/api/projects/route.ts @@ -0,0 +1,238 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '../../../lib/db'; +import { projects, teamMembers, projectOptions, categories, categoryOptionValues } from '../../../lib/schema'; +import { eq, and, sql } from 'drizzle-orm'; + +export async function GET(req: NextRequest) { + try { + const allProjects = await db.select().from(projects).leftJoin(teamMembers, eq(projects.projectId, teamMembers.projectId)); + + // Group members by project + const projectsWithMembers: any[] = allProjects.reduce((acc: any[], row) => { + const existingProject = acc.find(p => p.projectId === row.projects.projectId); + if (existingProject) { + if (row.team_members) { + existingProject.members.push(row.team_members); + } + } else { + acc.push({ + ...row.projects, + members: row.team_members ? [row.team_members] : [], + }); + } + return acc; + }, []); + + // Fetch categories and options for each project based on the new schema + const finalProjects = await Promise.all(projectsWithMembers.map(async (project) => { + const projectCategoryOptions: { categoryName: string; optionName: string }[] = []; + + const projectOpts = await db.select({ + categoryId: projectOptions.categoryId, + optionId: projectOptions.optionId + }) + .from(projectOptions) + .where(eq(projectOptions.projectId, project.projectId)); + + for (const opt of projectOpts) { + const category = await db.select({ categoryName: categories.category }).from(categories).where(eq(categories.categoryId, opt.categoryId!)); + if (category.length > 0) { + const optionValue = await db.select({ optionName: categoryOptionValues.optionName }).from(categoryOptionValues).where(eq(categoryOptionValues.optionId, opt.optionId!)); + + if (optionValue.length > 0) { + projectCategoryOptions.push({ + categoryName: category[0].categoryName!, + optionName: optionValue[0].optionName!, + }); + } + } + } + + return { + ...project, + categories: projectCategoryOptions, // Attach the generic categories array + }; + })); + + console.log('GET /api/projects - Final projects data sent:', JSON.stringify(finalProjects, null, 2)); + + return NextResponse.json(finalProjects); + } catch (error) { + console.error('Error fetching projects:', error); + return NextResponse.json({ message: 'Failed to fetch projects.' }, { status: 500 }); + } +} + +export async function POST(req: NextRequest) { + try { + const { projectName, projectDescription, projectLink, createdAt, members, projectCategoryOptions, customDomain } = await req.json(); + + console.log('POST /api/projects - Incoming project data:', { projectName, projectDescription, projectCategoryOptions, customDomain }); + + const [newProject] = await db.insert(projects).values({ + projectName, + projectDescription, + projectLink, + createdAt: new Date(createdAt), + customDomain: customDomain, // Save customDomain directly + }).returning({ projectId: projects.projectId }); + + if (!newProject || !newProject.projectId) { + console.error('Failed to insert new project:', projectName); + return NextResponse.json({ message: 'Failed to create project.' }, { status: 500 }); + } + + console.log('POST /api/projects - New project created with ID:', newProject.projectId); + + if (members && members.length > 0) { + for (const member of members) { + await db.insert(teamMembers).values({ + projectId: newProject.projectId, + name: member.name, + linkedin: member.linkedin, + }); + } + console.log('POST /api/projects - Team members inserted.'); + } + + // Link project to categories using the new dynamic schema + if (projectCategoryOptions && newProject.projectId) { + for (const mapping of projectCategoryOptions) { + console.log(`POST /api/projects - Processing category mapping: ${mapping.categoryName} = ${mapping.optionName}`); + if (mapping.optionName) { + const category = await db.select({ categoryId: categories.categoryId }).from(categories).where(eq(categories.category, mapping.categoryName)); + console.log(`POST /api/projects - Category lookup result for ${mapping.categoryName}:`, category); + + if (category.length > 0 && category[0].categoryId) { + // Determine the actual option name to link in categoryOptionValues + // If it's the 'Domain' category and customDomain is provided, link to 'Other' + const actualOptionNameForLinking = (mapping.categoryName === 'Domain' && customDomain) ? 'Other' : mapping.optionName; + + const option = await db.select({ optionId: categoryOptionValues.optionId }).from(categoryOptionValues) + .where(and( + eq(categoryOptionValues.optionName, actualOptionNameForLinking), + eq(categoryOptionValues.categoryId, category[0].categoryId) + )); + console.log(`POST /api/projects - Option lookup result for ${actualOptionNameForLinking} in category ${mapping.categoryName}:`, option); + + if (option.length > 0 && option[0].optionId) { + console.log('POST /api/projects - Inserting into projectOptions:', { projectId: newProject.projectId, categoryId: category[0].categoryId, optionId: option[0].optionId }); + await db.insert(projectOptions).values({ + projectId: newProject.projectId, + categoryId: category[0].categoryId, + optionId: option[0].optionId, + }); + console.log('POST /api/projects - Inserted into projectOptions.'); + } else { + console.warn(`POST /api/projects - Option not found for ${actualOptionNameForLinking} in category ${mapping.categoryName}. Skipping linking.`); + } + } else { + console.warn(`POST /api/projects - Category not found for name: ${mapping.categoryName}. Skipping linking.`); + } + } else { + console.log(`POST /api/projects - Option name is empty for category ${mapping.categoryName}. Skipping linking.`); + } + } + } + + return NextResponse.json(newProject, { status: 201 }); + } catch (error) { + console.error('Error adding project:', error); + return NextResponse.json({ message: 'Failed to add project.' }, { status: 500 }); + } +} + +export async function PUT(req: NextRequest) { + try { + const { projectId, projectName, projectDescription, projectLink, createdAt, members, projectCategoryOptions, customDomain } = await req.json(); + + console.log('PUT /api/projects - Incoming project data:', { projectId, projectName, projectDescription, projectCategoryOptions, customDomain }); + + if (!projectId) { + return NextResponse.json({ message: 'Project ID is required for update.' }, { status: 400 }); + } + + // Update project details + await db.update(projects).set({ + projectName, + projectDescription, + projectLink, + createdAt: new Date(createdAt), + customDomain: customDomain, // Save customDomain directly + }).where(eq(projects.projectId, projectId)); + + console.log('PUT /api/projects - Project details updated.'); + + // Update team members: clear existing and insert new ones + await db.delete(teamMembers).where(eq(teamMembers.projectId, projectId)); + if (members && members.length > 0) { + for (const member of members) { + await db.insert(teamMembers).values({ + projectId: projectId, + name: member.name, + linkedin: member.linkedin, + }); + } + console.log('PUT /api/projects - Team members updated.'); + } + + // Update project category options: clear existing and insert new ones + await db.delete(projectOptions).where(eq(projectOptions.projectId, projectId)); + if (projectCategoryOptions && projectCategoryOptions.length > 0) { + for (const mapping of projectCategoryOptions) { + console.log(`PUT /api/projects - Processing category mapping: ${mapping.categoryName} = ${mapping.optionName}`); + if (mapping.optionName) { + const category = await db.select({ categoryId: categories.categoryId }).from(categories).where(eq(categories.category, mapping.categoryName)); + + if (category.length > 0 && category[0].categoryId) { + // Determine the actual option name to link in categoryOptionValues + // If it's the 'Domain' category and customDomain is provided, link to 'Other' + const actualOptionNameForLinking = (mapping.categoryName === 'Domain' && customDomain) ? 'Other' : mapping.optionName; + + const option = await db.select({ optionId: categoryOptionValues.optionId }).from(categoryOptionValues) + .where(and( + eq(categoryOptionValues.optionName, actualOptionNameForLinking), + eq(categoryOptionValues.categoryId, category[0].categoryId) + )); + console.log(`PUT /api/projects - Option lookup result for ${actualOptionNameForLinking} in category ${mapping.categoryName}:`, option); + + if (option.length > 0 && option[0].optionId) { + await db.insert(projectOptions).values({ + projectId: projectId, + categoryId: category[0].categoryId, + optionId: option[0].optionId, + }); + } else { + console.warn(`PUT /api/projects - Option not found for ${actualOptionNameForLinking} in category ${mapping.categoryName}. Skipping linking.`); + } + } else { + console.warn(`PUT /api/projects - Category not found for name: ${mapping.categoryName}. Skipping linking.`); + } + } else { + console.log(`PUT /api/projects - Option name is empty for category ${mapping.categoryName}. Skipping linking.`); + } + } + console.log('PUT /api/projects - Project category options updated.'); + } + + return NextResponse.json({ message: 'Project updated successfully.' }, { status: 200 }); + } catch (error) { + console.error('Error updating project:', error); + return NextResponse.json({ message: 'Failed to update project.' }, { status: 500 }); + } +} + +export async function DELETE(req: NextRequest) { + try { + const { projectId } = await req.json(); + + await db.delete(projectOptions).where(eq(projectOptions.projectId, projectId)); + await db.delete(teamMembers).where(eq(teamMembers.projectId, projectId)); + await db.delete(projects).where(eq(projects.projectId, projectId)); + + return NextResponse.json({ message: 'Project deleted successfully.' }); + } catch (error) { + console.error('Error deleting project:', error); + return NextResponse.json({ message: 'Failed to delete project.' }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/saveProject/route.ts b/app/api/saveProject/route.ts deleted file mode 100644 index 7e43368..0000000 --- a/app/api/saveProject/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { NextResponse } from "next/server"; -import fs from "fs"; -import path from "path"; - -export async function POST(req: Request) { - const data = await req.json(); - const filePath = path.join(process.cwd(), "data", "projects.json"); - - // Read existing projects - let projects = []; - if (fs.existsSync(filePath)) { - const jsonData = fs.readFileSync(filePath, "utf-8"); - projects = JSON.parse(jsonData); - } - - // Add new project - projects.push(data); - - // Save to file - fs.writeFileSync(filePath, JSON.stringify(projects, null, 2)); - - return NextResponse.json({ message: "Project saved successfully!" }, { status: 200 }); -} diff --git a/app/components/AdminLayout.tsx b/app/components/AdminLayout.tsx new file mode 100644 index 0000000..a461011 --- /dev/null +++ b/app/components/AdminLayout.tsx @@ -0,0 +1,31 @@ +import Link from 'next/link'; + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + return ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+ {children} +
+
+ ); +} \ No newline at end of file diff --git a/app/manage-categories/page.tsx b/app/manage-categories/page.tsx new file mode 100644 index 0000000..4d71c53 --- /dev/null +++ b/app/manage-categories/page.tsx @@ -0,0 +1,352 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import TableSkeleton from '@/components/repeto/TableSkeleton'; + +interface CategoryOption { + optionId?: number; + optionName: string; +} + +interface Category { + categoryId?: number; + categoryName: string; + options: CategoryOption[]; +} + +export default function ManageCategoriesPage() { + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [currentCategory, setCurrentCategory] = useState(null); + const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false); + const [categoryToDeleteId, setCategoryToDeleteId] = useState(null); + const [deleteErrorType, setDeleteErrorType] = useState<"none" | "foreignKeyBlocked" | null>(null); + + useEffect(() => { + fetchCategories(); + }, []); + + const fetchCategories = async () => { + const CACHE_KEY = 'cachedCategories'; + const CACHE_EXPIRATION_TIME = 60 * 60 * 1000; // 1 hour in milliseconds + + const cachedData = localStorage.getItem(CACHE_KEY); + if (cachedData) { + const { data, timestamp } = JSON.parse(cachedData); + if (Date.now() - timestamp < CACHE_EXPIRATION_TIME) { + setCategories(data); + setLoading(false); + console.log('Categories loaded from cache in manage-categories.'); + return; // Use cached data and exit + } + } + + try { + const res = await fetch('/api/categories'); + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + const data: Category[] = await res.json(); + setCategories(data); + console.log('Categories fetched and set:', data); + localStorage.setItem(CACHE_KEY, JSON.stringify({ data, timestamp: Date.now() })); + } catch (e: any) { + setError(e.message); + console.error("Failed to fetch categories:", e); + } finally { + setLoading(false); + } + }; + + const handleAddCategory = () => { + setCurrentCategory({ categoryName: '', options: [] }); + setIsModalOpen(true); + }; + + const handleEditCategory = (category: Category) => { + setCurrentCategory({ ...category }); + setIsModalOpen(true); + }; + + const handleDeleteCategory = (categoryId: number) => { + setCategoryToDeleteId(categoryId); + setShowDeleteConfirmModal(true); + setDeleteErrorType("none"); + }; + + const confirmDeleteCategory = async (forceDelete: boolean = false) => { + if (categoryToDeleteId === null) return; + + let operationSuccessful = false; // Flag to track if the main operation was successful + try { + const res = await fetch('/api/categories', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ categoryId: categoryToDeleteId, forceDelete }), // Add forceDelete flag + }); + + if (!res.ok) { + // Attempt to parse error response if available + const errorData = await res.json().catch(() => ({ message: res.statusText, code: null })); // Ensure code is initialized + const errorMessage = errorData.message || res.statusText; + const errorCode = errorData.code; // Extract code if available + + // Check for specific foreign key constraint error (based on your console output) + if (errorMessage.includes('violates foreign key constraint') || (errorCode === '23503')) { + setDeleteErrorType("foreignKeyBlocked"); + setError("This deletion affects the database, because this category is used in some projects. If you want to delete the category, remove the category from the projects and then delete the category."); // Set the specific error message + return; // Prevent further execution, keep modal open + } else { + throw new Error(`HTTP error! status: ${res.status} - ${errorMessage}`); + } + } + localStorage.removeItem('cachedCategories'); // Invalidate cache + setCategories(categories.filter((c) => c.categoryId !== categoryToDeleteId)); + fetchCategories(); // Re-fetch categories to ensure UI is updated + operationSuccessful = true; // Set flag to true on successful operation + + } catch (e: any) { + // This catch block will now primarily handle network errors or other unexpected errors + // not explicitly handled by the !res.ok branch. + setError(e.message || "An unexpected error occurred."); + console.error("Failed to delete category:", e); + setShowDeleteConfirmModal(false); // Close modal for generic errors + setCategoryToDeleteId(null); // Clear id for generic errors + setDeleteErrorType("none"); // Reset type for generic errors + } finally { + // This finally block now handles modal closing only if it wasn't a foreignKeyBlocked scenario + // and the operation was successful. + if (operationSuccessful) { + setShowDeleteConfirmModal(false); + setCategoryToDeleteId(null); + setDeleteErrorType("none"); // Reset after successful delete + } + } + }; + + const cancelDelete = () => { + setShowDeleteConfirmModal(false); + setCategoryToDeleteId(null); + setDeleteErrorType("none"); // Reset on cancel + setError(null); // Clear any displayed error message + }; + + const handleSaveCategory = async (e: React.FormEvent) => { + e.preventDefault(); + if (!currentCategory) return; + + const method = currentCategory.categoryId ? 'PUT' : 'POST'; + const url = '/api/categories'; + + console.log('Sending category data:', JSON.stringify(currentCategory, null, 2)); + + try { + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(currentCategory), + }); + + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + + localStorage.removeItem('cachedCategories'); + fetchCategories(); + setIsModalOpen(false); + setCurrentCategory(null); + } catch (e: any) { + setError(e.message); + console.error("Failed to save category:", e); + } + }; + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setCurrentCategory((prev) => { + if (!prev) return null; + return { ...prev, [name]: value }; + }); + }; + + const handleOptionChange = (index: number, e: React.ChangeEvent) => { + const { name, value } = e.target; + setCurrentCategory((prev) => { + if (!prev) return null; + const updatedOptions = [...prev.options]; + updatedOptions[index] = { ...updatedOptions[index], [name]: value }; + return { ...prev, options: updatedOptions }; + }); + }; + + const handleAddOption = () => { + setCurrentCategory((prev) => { + if (!prev) return null; + return { ...prev, options: [...prev.options, { optionName: '' }] }; + }); + }; + + const handleRemoveOption = (index: number) => { + setCurrentCategory((prev) => { + if (!prev) return null; + const updatedOptions = prev.options.filter((_, i) => i !== index); + return { ...prev, options: updatedOptions }; + }); + }; + + if (loading) return ; + if (error) return
Error: {error}
; + + return ( +
+
+

Manage Categories

+ + + +
+ + + + + + + + + + {categories.map((category) => ( + + + + + + ))} + +
Category NameOptionsActions
{category.categoryName} + {category.options.map(o => o.optionName).join(', ')} + + + +
+
+ + {isModalOpen && ( +
+
+

{currentCategory?.categoryId ? 'Edit Category' : 'Add New Category'}

+
+
+ + +
+ +

Options

+ {currentCategory?.options.map((option, index) => ( +
+ handleOptionChange(index, e)} + className="flex-1 border border-gray-300 rounded-md shadow-sm p-2 mr-2 w-full mb-2 sm:mb-0" + placeholder="Option Name" + required + /> + +
+ ))} + + +
+ + +
+
+
+
+ )} + + {showDeleteConfirmModal && ( +
+
+

+ {deleteErrorType === "foreignKeyBlocked" ? "Deletion Blocked" : "Confirm Delete"} +

+

+ {deleteErrorType === "foreignKeyBlocked" + ? error // Display the specific error message from state + : "Are you sure you want to delete this category? This action cannot be undone."} +

+
+ + {deleteErrorType !== "foreignKeyBlocked" && ( // Only show Delete button if not blocked + + )} +
+
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/app/manage-projects/page.tsx b/app/manage-projects/page.tsx new file mode 100644 index 0000000..f1faacc --- /dev/null +++ b/app/manage-projects/page.tsx @@ -0,0 +1,472 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import TableSkeleton from '@/components/repeto/TableSkeleton'; + +interface TeamMember { + memberId?: number; + name: string; + linkedin?: string; +} + +interface Project { + projectId?: number; + projectName: string; + projectDescription?: string; + projectLink?: string; + createdAt: string; + members: TeamMember[]; + selectedCategoryOptions: Record; // Map category name to selected option name + customDomain?: string; // Keep customDomain separate if 'Domain' is 'Other' + categories?: { categoryName: string; optionName: string; customDomain?: string }[]; +} + +interface CategoryOption { + optionId: number; + optionName: string; +} + +interface Category { + categoryId: number; + categoryName: string; + options: CategoryOption[]; +} + +export default function ManageProjectsPage() { + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [currentProject, setCurrentProject] = useState(null); + + const [categories, setCategories] = useState([]); + const [loadingCategories, setLoadingCategories] = useState(true); + const [errorCategories, setErrorCategories] = useState(null); + + useEffect(() => { + // console.time('Initial Data Fetch'); + fetchProjects(); + const fetchCategories = async () => { + // console.time('fetchCategories'); + const CACHE_KEY = 'cachedCategories'; + const CACHE_EXPIRATION_TIME = 60 * 60 * 1000; // 1 hour in milliseconds + + const cachedData = localStorage.getItem(CACHE_KEY); + if (cachedData) { + const { data, timestamp } = JSON.parse(cachedData); + if (Date.now() - timestamp < CACHE_EXPIRATION_TIME) { + setCategories(data); + setLoadingCategories(false); + console.log('Categories loaded from cache.'); + return; // Use cached data and exit + } + } + + try { + const res = await fetch('/api/categories'); + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + const data: Category[] = await res.json(); + setCategories(data); + localStorage.setItem(CACHE_KEY, JSON.stringify({ data, timestamp: Date.now() })); + // console.timeEnd('fetchCategories'); + } catch (e: any) { + setErrorCategories(e.message); + console.error("Failed to fetch categories for form:", e); + // console.timeEnd('fetchCategories'); + } finally { + setLoadingCategories(false); + } + }; + fetchCategories(); + }, []); + + const fetchProjects = async () => { + // console.time('fetchProjects'); + try { + const res = await fetch('/api/projects'); + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + const data: Project[] = await res.json(); + console.log('Fetched Projects Data:', data); + setProjects(data); + // console.timeEnd('fetchProjects'); + } catch (e: any) { + setError(e.message); + console.error("Failed to fetch projects:", e); + // console.timeEnd('fetchProjects'); + } finally { + setLoading(false); + // console.timeEnd('Initial Data Fetch'); + } + }; + + const handleAddProject = () => { + setCurrentProject({ + projectName: '', + projectDescription: '', + projectLink: '', + createdAt: new Date().toISOString().split('T')[0], + members: [{ name: '', linkedin: '' }], + selectedCategoryOptions: {}, + }); + setIsModalOpen(true); + }; + + const handleEditProject = (project: Project) => { + console.log('Editing Project:', project); + const selectedCategoryOptions: Record = {}; + + // Initialize all categories with existing project values or empty string + categories.forEach(category => { + const projectOption = project.categories?.find( + pco => pco.categoryName.toLowerCase() === category.categoryName.toLowerCase() + ); + selectedCategoryOptions[category.categoryName] = projectOption?.optionName.trim() || ''; + if (category.categoryName === 'Year of Submission') { + console.log(`handleEditProject - Year of Submission - projectOption: ${projectOption?.optionName}, trimmed: ${projectOption?.optionName.trim()}, selectedCategoryOptions: ${selectedCategoryOptions[category.categoryName]}`); + } + }); + + setCurrentProject({ + ...project, + createdAt: project.createdAt.split('T')[0], + selectedCategoryOptions, + customDomain: project.customDomain || '', + }); + setIsModalOpen(true); + }; + + const handleDeleteProject = async (projectId: number) => { + if (window.confirm('Are you sure you want to delete this project?')) { + try { + const res = await fetch('/api/projects', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ projectId }), + }); + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + setProjects(projects.filter((p) => p.projectId !== projectId)); + } catch (e: any) { + setError(e.message); + console.error("Failed to delete project:", e); + } + } + }; + + const handleSaveProject = async (e: React.FormEvent) => { + e.preventDefault(); + if (!currentProject) return; + + const hasValidMember = currentProject.members.some( + (member) => member.name.trim() !== "" + ); + if (!hasValidMember) { + alert("Please enter at least one member name."); + return; + } + + const filteredMembers = currentProject.members.filter( + (member) => member.name.trim() !== "" + ); + + // Prepare category options for backend + const projectCategoryOptions: { categoryName: string; optionName: string; customDomain?: string }[] = Object.entries(currentProject.selectedCategoryOptions).map(([categoryName, optionName]) => ({ + categoryName, + optionName: categoryName === 'Domain' && optionName === 'Other' ? (currentProject.customDomain || '') : optionName // Use customDomain if 'Other' domain is selected + })); + + const projectData = { + projectId: currentProject.projectId, // Include projectId for PUT requests + projectName: currentProject.projectName, + projectDescription: currentProject.projectDescription, + projectLink: currentProject.projectLink, + createdAt: currentProject.createdAt, + members: filteredMembers, + projectCategoryOptions, // Send as a generic array of category options + customDomain: currentProject.selectedCategoryOptions['Domain'] === 'Other' ? currentProject.customDomain : undefined, // Send customDomain separately + }; + + const method = currentProject.projectId ? 'PUT' : 'POST'; + const url = '/api/projects'; + + try { + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(projectData), + }); + + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + + fetchProjects(); // Refresh the list + setIsModalOpen(false); + setCurrentProject(null); + } catch (e: any) { + setError(e.message); + console.error("Failed to save project:", e); + } + }; + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setCurrentProject((prev) => { + if (!prev) return null; + if (name.startsWith("category-")) { + const categoryName = name.replace("category-", ""); + return { + ...prev, + selectedCategoryOptions: { + ...prev.selectedCategoryOptions, + [categoryName]: value.trim(), + }, + // Clear customDomain if the Domain category is not 'Other' + customDomain: categoryName === 'Domain' && value !== 'Other' ? '' : prev.customDomain, + }; + } else if (name === "customDomain") { + return { ...prev, customDomain: value }; + } else { + return { ...prev, [name]: value }; + } + }); + if (name.startsWith("category-") && name.replace("category-", "") === 'Year of Submission') { + console.log(`handleChange - Year of Submission - name: ${name}, value: ${value}, trimmed: ${value.trim()}`); + } + }; + + const handleMemberChange = (index: number, e: React.ChangeEvent) => { + const { name, value } = e.target; + setCurrentProject((prev) => { + if (!prev) return null; + const updatedMembers = [...prev.members]; + updatedMembers[index] = { ...updatedMembers[index], [name]: value }; + return { ...prev, members: updatedMembers }; + }); + }; + + const handleAddMember = () => { + setCurrentProject((prev) => { + if (!prev) return null; + return { ...prev, members: [...prev.members, { name: '', linkedin: '' }] }; + }); + }; + + const handleRemoveMember = (index: number) => { + setCurrentProject((prev) => { + if (!prev) return null; + const updatedMembers = prev.members.filter((_, i) => i !== index); + return { ...prev, members: updatedMembers }; + }); + }; + + if (loading) return ; + if (error) return
Error: {error}
; + + return ( +
+
+

Manage Projects

+ + + +
+ + + + + + + + + + {projects.map((project) => ( + + + + + + ))} + +
Project NameDescriptionActions
{project.projectName}{project.projectDescription?.substring(0, 50)}... + + +
+
+ + {isModalOpen && currentProject && ( +
+
+

+ {currentProject.projectId ? 'Edit Project' : 'Add Project'} +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + {/* Dynamic Category Selects */} + {categories.length > 0 && categories.map(category => ( +
+ + + {category.categoryName === 'Domain' && currentProject.selectedCategoryOptions['Domain'] === 'Other' && ( + + )} +
+ ))} + + {/* Members */} +

Team Members

+ {currentProject.members.map((member, index) => ( +
+
+ + handleMemberChange(index, e)} + className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" + required + /> +
+
+ + handleMemberChange(index, e)} + className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm p-2" + /> +
+ +
+ ))} + + +
+ + +
+
+
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index 263ab53..e22200d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -7,18 +7,22 @@ import FilterSection from '@/components/repeto/FilterSection'; import TabSection from '@/components/repeto/TabSection'; // import Footer from '@/components/Footer'; import AddProjectFAB from '@/components/repeto/AddProjectFAB'; -import LoadingScreen from "@/components/loadingScrenn"; +// import LoadingScreen from "@/components/loadingScrenn"; +import Link from 'next/link'; export default function Home() { const [activeTab, setActiveTab] = useState("All"); const [filters, setFilters] = useState>({}); - const [loading, setLoading] = useState(true); // Track loading state + const [refreshTrigger, setRefreshTrigger] = useState(false); // State to trigger project re-fetch + // const [loading, setLoading] = useState(true); // Track loading state useEffect(() => { // Simulate a loading delay - const timer = setTimeout(() => setLoading(false), 1500); - return () => clearTimeout(timer); + // const timer = setTimeout(() => setLoading(false), 1500); + // return () => clearTimeout(timer); + setRefreshTrigger(prev => !prev); // Trigger re-fetch when component mounts + }, []); @@ -28,18 +32,18 @@ export default function Home() { }; // Show the loading screen first - if (loading) { - return ; - } + // if (loading) { + // return ; + // } return (
-
+
-
+
- +
diff --git a/components/admin-components.tsx b/components/admin-components.tsx new file mode 100644 index 0000000..3809c03 --- /dev/null +++ b/components/admin-components.tsx @@ -0,0 +1,309 @@ +import React, { useEffect, useState } from "react"; +import { firestore, auth } from "@/lib/firebase/config"; +import { + collection, + doc, + getDoc, + setDoc, + deleteDoc, + getDocs, + Timestamp, +} from "firebase/firestore"; + +export const AddAdmin = () => { + const [email, setEmail] = useState(""); + const [role, setRole] = useState<"admin" | "superadmin">("admin"); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [confirming, setConfirming] = useState(false); + + const handleAdd = async () => { + setStatus(null); + setLoading(true); + const currentUser = auth.currentUser; + if (!currentUser) { + setStatus("Must be logged in."); + setLoading(false); + return; + } + + const currentUserRef = doc(firestore, "adminemail", currentUser.email!); + const currentUserSnap = await getDoc(currentUserRef); + + if (!currentUserSnap.exists() || currentUserSnap.data().role !== "superadmin") { + setStatus("Not authorized."); + setLoading(false); + return; + } + + try { + const targetRef = doc(firestore, "adminemail", email); + const targetSnap = await getDoc(targetRef); + + // If already exists, delete first + if (targetSnap.exists()) { + await deleteDoc(targetRef); + } + + await setDoc(targetRef, { + role, + addedBy: currentUser.email, + addedAt: Timestamp.now(), + isActive: true, + }); + + setStatus(`Admin added successfully with role: ${role}`); + setEmail(""); + setRole("admin"); + setConfirming(false); + } catch (err: unknown) { + if (err instanceof Error) { + setStatus("Error: " + err.message); + } else { + setStatus("An unknown error occurred."); + } + } finally { + setLoading(false); + } + }; + + return ( +
+ setEmail(e.target.value)} + className="border p-2 w-full rounded mb-2" + /> + + + {!confirming ? ( + + ) : ( +
+ + +
+ )} + + {status &&

{status}

} +
+ ); +}; + +export const AdminList = () => { + interface Admin { + id: string; + role: string; + addedBy: string; + addedAt: Timestamp; + isActive: boolean; + } + + const [admins, setAdmins] = useState([]); + + useEffect(() => { + const fetchAdmins = async () => { + const snap = await getDocs(collection(firestore, "adminemail")); + const list = snap.docs.map((doc) => ({ + id: doc.id, + role: doc.data().role, + addedBy: doc.data().addedBy, + addedAt: doc.data().addedAt, + isActive: doc.data().isActive, + })); + setAdmins(list); + }; + fetchAdmins(); + }, []); + + return ( +
+ {admins.map((admin) => ( +
+

+ Email: {admin.id} +

+

+ Role: {admin.role} +

+

+ Added By: {admin.addedBy} +

+

+ Added At:{" "} + {admin.addedAt?.toDate().toLocaleString()} +

+
+ ))} +
+ ); +}; + +export const RemoveAdmin = () => { + const [email, setEmail] = useState(""); + const [status, setStatus] = useState(null); + const [confirming, setConfirming] = useState(false); + + const handleRemove = async () => { + setStatus(null); + const currentUser = auth.currentUser; + if (!currentUser) { + setStatus("Must be logged in."); + return; + } + + const ref = doc(firestore, "adminemail", currentUser.email!); + const snap = await getDoc(ref); + if (!snap.exists() || snap.data().role !== "superadmin") { + setStatus("Not authorized."); + return; + } + + try { + const removedAdmin = await getDoc(doc(firestore, "adminemail", email)); + if (!removedAdmin.exists()) { + setStatus("Admin not found."); + return; + } + + await deleteDoc(doc(firestore, "adminemail", email)); + + await setDoc(doc(firestore, "removedadmin", email), { + email, + removedBy: currentUser.email, + removedAt: Timestamp.now(), + roleAtRemoval: removedAdmin.data().role, + }); + + setStatus("Admin removed."); + setEmail(""); + setConfirming(false); + } catch (err: unknown) { + if (err instanceof Error) { + setStatus("Error: " + err.message); + } else { + setStatus("An unknown error occurred."); + } + } + }; + + return ( +
+ setEmail(e.target.value)} + className="border p-2 w-full rounded mb-2" + /> + {!confirming ? ( + + ) : ( +
+ + +
+ )} + {status &&

{status}

} + +
+ ); +}; + +export const RemovedAdminsList = () => { + interface RemovedAdmin { + id: string; + email: string; + removedBy: string; + roleAtRemoval: string; + removedAt: Timestamp; + } + + const [removed, setRemoved] = useState([]); + + useEffect(() => { + const fetchRemoved = async () => { + const snap = await getDocs(collection(firestore, "removedadmin")); + const list = snap.docs.map((doc) => ({ + id: doc.id, + email: doc.data().email, + removedBy: doc.data().removedBy, + roleAtRemoval: doc.data().roleAtRemoval, + removedAt: doc.data().removedAt, + })); + setRemoved(list); + }; + fetchRemoved(); + }, []); + + return ( +
+

Removed Admins

+ {removed.map((admin) => ( +
+

+ Email: {admin.email} +

+

+ Removed By: {admin.removedBy} +

+

+ Role: {admin.roleAtRemoval} +

+

+ Removed At:{" "} + {admin.removedAt?.toDate().toLocaleString()} +

+
+ ))} +
+ ); +}; \ No newline at end of file diff --git a/components/repeto/FilterSection.tsx b/components/repeto/FilterSection.tsx index 9ffe2a5..e53118e 100644 --- a/components/repeto/FilterSection.tsx +++ b/components/repeto/FilterSection.tsx @@ -1,6 +1,5 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { ChevronDown, ChevronUp, Filter, X } from 'lucide-react'; -import filtersData from '@/data/filters.json'; interface FilterOption { title: string; @@ -14,10 +13,35 @@ interface FilterSectionProps { export default function FilterSection({ onFilterSubmit, onClearFilters }: FilterSectionProps) { const [isOpen, setIsOpen] = useState(false); - const [filters] = useState(filtersData); + const [filters, setFilters] = useState([]); const [selectedOptions, setSelectedOptions] = useState>>({}); const [openDropdowns, setOpenDropdowns] = useState>({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + useEffect(() => { + fetchFilterOptions(); + }, []); + + const fetchFilterOptions = async () => { + try { + const res = await fetch('/api/categories'); // Assuming this endpoint provides categories and their options + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + const data = await res.json(); + const formattedFilters: FilterOption[] = data.map((category: any) => ({ + title: category.categoryName, + options: category.options.map((option: any) => option.optionName), + })); + setFilters(formattedFilters); + } catch (e: any) { + setError(e.message); + console.error("Failed to fetch filter options:", e); + } finally { + setLoading(false); + } + }; const toggleDropdown = (filterTitle: string) => { setOpenDropdowns(prev => ({ @@ -67,6 +91,22 @@ export default function FilterSection({ onFilterSubmit, onClearFilters }: Filter 0 ); + if (loading) { + return ( +
+
Loading filters...
+
+ ); + } + + if (error) { + return ( +
+
Error: {error}
+
+ ); + } + const FilterContent = () => (
{filters.map((filter) => ( diff --git a/components/repeto/ProjectCard.tsx b/components/repeto/ProjectCard.tsx index a67f35f..505989b 100644 --- a/components/repeto/ProjectCard.tsx +++ b/components/repeto/ProjectCard.tsx @@ -14,6 +14,15 @@ export default function ProjectCard({ project }: ProjectCardProps) { setExpanded(!expanded); }; + // Helper to find category option by category name + const getCategoryOption = (categoryName: string) => { + return project.categories?.find(cat => cat.categoryName === categoryName)?.optionName; + }; + + const yearOfSubmission = getCategoryOption("Year of Submission"); + const projectType = getCategoryOption("Project Type"); + const domain = getCategoryOption("Domain"); + return (

Year

-

{project.yearOfSubmission}

+

{yearOfSubmission}

Project Type

-

{project.projectType}

+

{projectType}

Domain

-

{project.customDomain || project.domain}

+

{domain === 'Other' ? project.customDomain || 'Other' : domain}

diff --git a/components/repeto/ProjectCardSkeleton.tsx b/components/repeto/ProjectCardSkeleton.tsx new file mode 100644 index 0000000..5bc8da4 --- /dev/null +++ b/components/repeto/ProjectCardSkeleton.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +const ProjectCardSkeleton: React.FC = () => { + return ( +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ ); +}; + +export default ProjectCardSkeleton; \ No newline at end of file diff --git a/components/repeto/ProjectGrid.tsx b/components/repeto/ProjectGrid.tsx index cb1cf5e..8b3f9cf 100644 --- a/components/repeto/ProjectGrid.tsx +++ b/components/repeto/ProjectGrid.tsx @@ -2,26 +2,52 @@ import { useEffect, useState } from "react"; import ProjectCard from "./ProjectCard"; -import projectsData from "@/data/projects.json"; import { Project } from "@/types/project"; +import ProjectCardSkeleton from './ProjectCardSkeleton'; // Import the skeleton component interface ProjectGridProps { activeTab: string; filters: Record; // Accept selected filters + refreshTrigger?: boolean; // New prop to trigger re-fetch } -const ProjectGrid = ({ activeTab, filters }: ProjectGridProps) => { +const ProjectGrid = ({ activeTab, filters, refreshTrigger }: ProjectGridProps) => { const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [allProjects, setAllProjects] = useState([]); // To store all fetched projects useEffect(() => { - let filteredProjects: Project[] = projectsData.map((p) => ({ - ...p, - createdAt: p.createdAt ?? new Date().toISOString(), - department: Array.isArray(p.department) ? p.department.join(", ") : p.department, - projectLink: p.projectLink ?? "", // Ensure it's always a string - customDomain: p.customDomain ?? "", - members: p.members ?? [] - })); + const fetchProjects = async () => { + try { + setLoading(true); // Set loading to true at the start of fetch + const res = await fetch('/api/projects'); + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + const data: Project[] = await res.json(); + // Ensure all necessary fields are present or defaulted + const formattedData: Project[] = data.map((p) => ({ + ...p, + createdAt: p.createdAt ?? new Date().toISOString(), + projectLink: p.projectLink ?? "", + members: p.members ?? [], + categories: p.categories ?? [], // Ensure categories array is present + })); + setAllProjects(formattedData); + setLoading(false); + } catch (e: any) { + setError(e.message); + console.error("Failed to fetch projects:", e); + setLoading(false); + } + }; + + fetchProjects(); + }, [refreshTrigger]); // Add refreshTrigger to dependency array to re-fetch when it changes + + useEffect(() => { + let filteredProjects: Project[] = [...allProjects]; // Apply Tab Filters const oneMonthAgo = new Date(); @@ -54,39 +80,48 @@ const ProjectGrid = ({ activeTab, filters }: ProjectGridProps) => { // Apply Filters if (Object.keys(filters).length > 0) { - Object.entries(filters).forEach(([key, values]) => { - if (values.length > 0) { - if (key === "Domain") { - filteredProjects = filteredProjects.filter((p) => - values.includes(p.domain) || values.includes("Others") - ); - } else if (key === "Department") { - filteredProjects = filteredProjects.filter((p) => - values.includes(p.department) - ); - } else if (key === "Year of Submission") { - filteredProjects = filteredProjects.filter((p) => - values.includes(p.yearOfSubmission) + Object.entries(filters).forEach(([filterCategoryName, selectedOptionNames]) => { + if (selectedOptionNames.length > 0) { + filteredProjects = filteredProjects.filter((project) => { + // Check if the project has the category being filtered + const projectCategory = project.categories?.find( + (cat) => cat.categoryName === filterCategoryName ); - } else if (key === "Project Type") { - filteredProjects = filteredProjects.filter((p) => - values.includes(p.projectType) - ); - } else { - filteredProjects = filteredProjects.filter((p) => { - const projectValue = p[key as keyof Project]; - if (typeof projectValue === "string") { - return values.includes(projectValue); - } - return false; - }); - } + + if (projectCategory) { + const isMatch = selectedOptionNames.includes(projectCategory.optionName); + + // console.log(`Filtering by Category: ${filterCategoryName}`); + // console.log(` Project Name: ${project.projectName}`); + // console.log(` Project's Category Options:`, project.categories); + // console.log(` Project's Option for ${filterCategoryName}: ${projectCategory.optionName}`); + // console.log(` Selected Filter Options: ${JSON.stringify(selectedOptionNames)}`); + // console.log(` Is Match: ${isMatch}`); + + return isMatch; + } + return false; // Project does not have this category, so it doesn't match the filter + }); } }); } setProjects(filteredProjects); - }, [activeTab, filters]); + }, [activeTab, filters, allProjects]); + + if (loading) { + return ( +
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+ ); + } + + if (error) { + return
Error: {error}
; + } return (
@@ -94,7 +129,7 @@ const ProjectGrid = ({ activeTab, filters }: ProjectGridProps) => { {projects.length > 0 ? ( projects.map((project, index) => (
diff --git a/components/repeto/TabSection.tsx b/components/repeto/TabSection.tsx index 347cf6b..13951b0 100644 --- a/components/repeto/TabSection.tsx +++ b/components/repeto/TabSection.tsx @@ -7,8 +7,8 @@ export default function TabSection({ activeTab, onTabChange }: TabSectionProps) const tabs = ["All", "Latest", "Oldest", "This Week",]; return ( -
-
+
+
{tabs.map((tab) => (