-
Our Board
+ Our Team
-
- {boardInfo.members.map((member, index) => {
+
+ {/* Board Members Section */}
+ {boardMembers.length > 0 && (
+
+
Board Members
+
+ {boardMembers
+ .sort((a, b) => a.displayOrder - b.displayOrder)
+ .map((member) => {
+ const socials = parseSocials(member.socials);
+ return (
+
+
+
+
+ {member.name}
+
+
+ {member.role}
+
+ {member.bio && (
+
+ {member.bio}
+
+ )}
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* Coordinators Section */}
+ {coordinators.length > 0 && (
+
+
Coordinators
+
+ {coordinators
+ .sort((a, b) => a.displayOrder - b.displayOrder)
+ .map((member) => {
+ const socials = parseSocials(member.socials);
return (
-
-
-
-
-
- {member.name} {member.surname}
+
+
+
+ {member.name}
-
- {member.position}
+
+ {member.role}
+ {member.bio && (
+
+ {member.bio}
+
+ )}
);
})}
+
+ )}
+
+ {/* Members Section */}
+ {members.length > 0 && (
+
+
Members
+
+ {members
+ .sort((a, b) => a.displayOrder - b.displayOrder)
+ .map((member) => {
+ const socials = parseSocials(member.socials);
+ return (
+
+
+
+
+ {member.name}
+
+ {member.role && (
+
+ {member.role}
+
+ )}
+ {member.bio && (
+
+ {member.bio}
+
+ )}
+
+
+ );
+ })}
+
+
+ )}
+
+ {totalMembers === 0 && (
+
+
No team member information available.
+
+ )}
+
+ {/* "And many more" text for 6+ members */}
+ {members.length >= 6 && (
+
+ )}
+
+ {/* View All Board Members Button */}
+ {totalMembers > 0 && (
+
+
+
+ Meet the Full Team
+
+
+
+ )}
);
};
diff --git a/frontend/app/(home)/_components/hero.tsx b/frontend/app/(home)/_components/hero.tsx
index 3ce4e4a..af212c5 100644
--- a/frontend/app/(home)/_components/hero.tsx
+++ b/frontend/app/(home)/_components/hero.tsx
@@ -54,10 +54,10 @@ export default function Hero() {
-
- Gradient Research Group
+
+ Gradient Science Club
-
+
We are a team of passionate students who are dedicated to exploring
the exciting field of machine learning. Our group provides a
platform for learning and growth in this rapidly advancing field.
diff --git a/frontend/app/(home)/_components/partnerships.tsx b/frontend/app/(home)/_components/partnerships.tsx
new file mode 100644
index 0000000..f42f29c
--- /dev/null
+++ b/frontend/app/(home)/_components/partnerships.tsx
@@ -0,0 +1,108 @@
+import React from "react";
+import { cn } from "@/lib/utils";
+import { partnershipsRepo } from "@/lib/repositories/partnerships";
+import type { Partnership } from "@/lib/types";
+import Image from "next/image";
+
+interface PartnershipsProps {
+ className?: string;
+}
+
+// Revalidate every 60 seconds to show new partnerships
+export const revalidate = 60;
+
+// Server component to fetch active partnerships
+async function getActivePartnerships(): Promise {
+ try {
+ return await partnershipsRepo.findActive();
+ } catch (error) {
+ console.error('Failed to fetch partnerships:', error);
+ return [];
+ }
+}
+
+// Helper function to format year range
+function formatYearRange(yearFrom: number, yearTo?: number): string {
+ if (!yearTo) {
+ return `${yearFrom} - Present`;
+ }
+ return `${yearFrom} - ${yearTo}`;
+}
+
+const Partnerships: React.FC = async ({ ...props }) => {
+ const partnerships = await getActivePartnerships();
+
+ // Don't render the section if there are no partnerships
+ if (partnerships.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
Our Partnerships
+
+ Organizations we collaborate with to advance scientific research and education
+
+
+
+
+ {partnerships
+ .sort((a, b) => a.displayOrder - b.displayOrder)
+ .map((partnership) => {
+ const CardWrapper = partnership.websiteUrl ? 'a' : 'div';
+ const cardProps = partnership.websiteUrl
+ ? {
+ href: partnership.websiteUrl,
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ className: 'flex flex-col items-center gap-3 md:gap-4 rounded-lg border border-gray-200 bg-white p-4 md:p-5 hover:shadow-md transition-shadow cursor-pointer'
+ }
+ : { className: 'flex flex-col items-center gap-3 md:gap-4 rounded-lg border border-gray-200 bg-white p-4 md:p-5' };
+
+ return (
+
+ {/* Logo */}
+
+ {(partnership.logoBase64 || partnership.logoUrl) ? (
+
+ ) : (
+
+ {partnership.name.charAt(0)}
+
+ )}
+
+
+ {/* Partnership Details */}
+
+
+ {partnership.name}
+
+
+ {formatYearRange(partnership.yearFrom, partnership.yearTo)}
+
+
+
+ );
+ })}
+
+
+ );
+};
+
+export default Partnerships;
\ No newline at end of file
diff --git a/frontend/app/(home)/_components/projects.tsx b/frontend/app/(home)/_components/projects.tsx
index e3c2352..5eee5b7 100644
--- a/frontend/app/(home)/_components/projects.tsx
+++ b/frontend/app/(home)/_components/projects.tsx
@@ -3,14 +3,49 @@ import React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
-import projectsFile from "@/public/data/projects.json";
+import { projectsRepo } from "@/lib/repositories";
+import type { Project } from "@/lib/types";
import Image from "next/image";
import Link from "next/link";
+import { Avatar } from "@/components/ui/avatar";
interface ProjectsProps extends React.HTMLProps {}
-const Projects: React.FC = ({ ...props }) => {
- let projects_path = "/images/projects/";
+// Revalidate every 60 seconds to show new projects
+export const revalidate = 60;
+
+// Server component to fetch featured projects with members
+async function getFeauturedProjects(): Promise {
+ try {
+ const projectsWithMembers = await projectsRepo.findAllWithMembers();
+ // Group projects by status for featured display
+ const statusOrder = { 'planned': 0, 'active': 1, 'completed': 2 };
+ const sortedProjects = projectsWithMembers.sort((a, b) => {
+ const aStatusOrder = statusOrder[a.status as keyof typeof statusOrder] ?? 3;
+ const bStatusOrder = statusOrder[b.status as keyof typeof statusOrder] ?? 3;
+
+ if (aStatusOrder !== bStatusOrder) {
+ return aStatusOrder - bStatusOrder;
+ }
+ // Within same status, sort by display order
+ return a.displayOrder - b.displayOrder;
+ });
+
+ return sortedProjects.slice(0, 6); // Get up to 6 featured projects
+ } catch (error) {
+ console.error('Failed to fetch featured projects with members:', error);
+ // Fallback to projects without members
+ try {
+ return await projectsRepo.findFeatured(6);
+ } catch (fallbackError) {
+ console.error('Failed to fetch featured projects (fallback):', fallbackError);
+ return [];
+ }
+ }
+}
+
+const Projects: React.FC = async ({ ...props }) => {
+ const projects = await getFeauturedProjects();
return (
= ({ ...props }) => {
"mt-12 flex flex-col flex-wrap gap-4 p-4 md:grid md:grid-cols-3",
)}
>
- {projectsFile.projects.map((project, index) => {
+ {projects.map((project) => {
return (
-
+
- {project.name}
+ {project.title}
+
+ {project.description || 'No description provided.'}
+
-
-
- Learn more {">"}
-
+
+ {/* Team Members */}
+ {project.members && project.members.length > 0 && (
+
+
Team:
+
+ {project.members.slice(0, 3).map((assignment) => (
+
+ ))}
+ {project.members.length > 3 && (
+
+ +{project.members.length - 3}
+
+ )}
+
+
+ )}
+
+
+
+ Learn more {">"}
+
+
+
+ {project.status}
+
+
+
);
})}
+ {projects.length === 0 && (
+
+
No projects available at the moment.
+
+ )}
+
+ {/* View All Projects Button */}
+ {projects.length > 0 && (
+
+
+
+ View All Projects
+
+
+
+ )}
);
};
diff --git a/frontend/app/(home)/layout.tsx b/frontend/app/(home)/layout.tsx
deleted file mode 100644
index bf2906c..0000000
--- a/frontend/app/(home)/layout.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import type { Metadata } from "next";
-import { Lato } from "next/font/google";
-import React from "react";
-import "../globals.css";
-
-const lato = Lato({ subsets: ["latin"], weight: ["300", "400", "700", "900"] });
-
-export const metadata: Metadata = {
- title: "Gradient",
- description: "Koło naukowe Gradient",
-};
-
-export default function RootLayout({
- children,
-}: {
- children: React.ReactNode;
-}) {
- return (
-
- {children}
-
- );
-}
diff --git a/frontend/app/(home)/page.tsx b/frontend/app/(home)/page.tsx
index 720053a..8588079 100644
--- a/frontend/app/(home)/page.tsx
+++ b/frontend/app/(home)/page.tsx
@@ -1,19 +1,19 @@
import Footer from "@/components/footer";
-import Plug from "@/components/plug";
import Board from "./_components/board";
import Cards from "./_components/cards";
import Hero from "./_components/hero";
import Projects from "./_components/projects";
+import Partnerships from "./_components/partnerships";
export default function Home() {
return (
-
-
-
-
+
+
+
+
);
}
diff --git a/frontend/app/admin/board/[id]/edit/page.tsx b/frontend/app/admin/board/[id]/edit/page.tsx
new file mode 100644
index 0000000..ca3f68c
--- /dev/null
+++ b/frontend/app/admin/board/[id]/edit/page.tsx
@@ -0,0 +1,761 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+import { ArrowLeft, Save, Trash2, RotateCcw } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { useToast } from "@/hooks/use-toast";
+import { useAutoSave } from '@/hooks/use-autosave';
+import { validateBoardMember } from '@/lib/validation';
+import ErrorBoundary from '@/components/ui/error-boundary';
+import Avatar from '@/components/ui/avatar';
+import ImageUpload from '@/components/ui/image-upload';
+import { FormField } from '@/components/ui/form-field';
+import type { BoardMember } from '@/lib/types';
+
+interface EditBoardMemberPageProps {
+ params: {
+ id: string;
+ };
+}
+
+interface FormData {
+ name: string;
+ role: string;
+ roleType: 'board_member' | 'coordinator' | 'member';
+ photoUrl: string;
+ photoBase64: string;
+ bio: string;
+ socials: string;
+ active: boolean;
+}
+
+interface FormErrors {
+ name?: string;
+ role?: string;
+ roleType?: string;
+ photoUrl?: string;
+ bio?: string;
+ socials?: string;
+}
+
+export default function EditBoardMemberPage({ params }: EditBoardMemberPageProps) {
+ const router = useRouter();
+ const { toast } = useToast();
+ const [member, setMember] = useState
(null);
+ const [isLoadingMember, setIsLoadingMember] = useState(true);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [errors, setErrors] = useState({});
+ const [isSubmitted, setIsSubmitted] = useState(false);
+
+ const [formData, setFormData] = useState({
+ name: '',
+ role: '',
+ roleType: 'member',
+ photoUrl: '',
+ photoBase64: '',
+ bio: '',
+ socials: '{"email": "", "linkedin": "", "github": ""}',
+ active: true,
+ });
+
+ // Track if user has made changes
+ const [hasUserMadeChanges, setHasUserMadeChanges] = useState(false);
+ const [originalFormData, setOriginalFormData] = useState(null);
+
+ // Auto-save functionality for edit form - only enabled after user makes changes
+ const { restoreSavedData, clearSavedData, hasSavedData } = useAutoSave({
+ key: `edit-board-member-${params.id}`,
+ data: formData,
+ enabled: !isLoading && !isLoadingMember && hasUserMadeChanges && member !== null,
+ onRestore: (data) => setFormData(data),
+ });
+
+ // Real-time form validation
+ const validateFormInRealTime = () => {
+ if (!isSubmitted) return;
+ const validation = validateBoardMember(formData);
+ setErrors(validation.errors);
+ };
+
+ useEffect(() => {
+ validateFormInRealTime();
+ }, [formData, isSubmitted]);
+
+ // Detect changes from original data
+ useEffect(() => {
+ if (originalFormData && !hasUserMadeChanges) {
+ const currentDataString = JSON.stringify(formData);
+ const originalDataString = JSON.stringify(originalFormData);
+
+ if (currentDataString !== originalDataString) {
+ setHasUserMadeChanges(true);
+ }
+ }
+ }, [formData, originalFormData, hasUserMadeChanges]);
+
+ // Load existing board member data
+ useEffect(() => {
+ const loadMember = async () => {
+ try {
+ const response = await fetch(`/api/admin/board/${params.id}`);
+
+ if (!response.ok) {
+ if (response.status === 404) {
+ toast({
+ title: "Board member not found",
+ description: "The requested board member could not be found.",
+ variant: "destructive",
+ });
+ router.push('/admin/board');
+ return;
+ }
+ throw new Error('Failed to load board member');
+ }
+
+ const data = await response.json();
+ const memberData = data.member;
+
+ setMember(memberData);
+
+ const originalData = {
+ name: memberData.name || '',
+ role: memberData.role || '',
+ roleType: memberData.roleType || 'board_member', // Default to board_member for existing records
+ photoUrl: memberData.photoUrl || '',
+ photoBase64: memberData.photoBase64 || '',
+ bio: memberData.bio || '',
+ socials: memberData.socials || '{"email": "", "linkedin": "", "github": ""}', // Prefill with empty structure
+ active: memberData.active !== undefined ? memberData.active : true,
+ };
+
+ // Store original data for comparison
+ setOriginalFormData(originalData);
+
+ // Always load original data first, let user choose to restore draft
+ setFormData(originalData);
+
+ // Show draft restore option if available
+ setTimeout(() => {
+ if (hasSavedData()) {
+ toast({
+ title: 'Unsaved changes found',
+ description: 'You have a draft with unsaved changes.',
+ action: (
+ {
+ const draftData = restoreSavedData();
+ if (draftData) {
+ setFormData(draftData);
+ setHasUserMadeChanges(true);
+ toast({
+ title: 'Draft restored',
+ description: 'Your previous changes have been restored.',
+ duration: 2000,
+ });
+ }
+ }}
+ >
+ Restore
+
+ ),
+ duration: 10000,
+ });
+ }
+ }, 500);
+ } catch (error) {
+ console.error('Error loading board member:', error);
+ toast({
+ title: "Error",
+ description: "Failed to load board member data.",
+ variant: "destructive",
+ });
+ router.push('/admin/board');
+ } finally {
+ setIsLoadingMember(false);
+ }
+ };
+
+ loadMember();
+ }, [params.id, router, toast]);
+
+ const isValidUrl = (url: string): boolean => {
+ try {
+ new URL(url);
+ return true;
+ } catch {
+ return false;
+ }
+ };
+
+ const isValidEmail = (email: string): boolean => {
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ return emailRegex.test(email);
+ };
+
+ const isValidSocialValue = (value: string): boolean => {
+ return isValidUrl(value) || isValidEmail(value);
+ };
+
+ const validateFormData = (): boolean => {
+ setIsSubmitted(true);
+ const validation = validateBoardMember(formData);
+ setErrors(validation.errors);
+ return validation.isValid;
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!validateFormData()) {
+ toast({
+ title: 'Validation Error',
+ description: 'Please fix the errors above before submitting.',
+ variant: 'destructive',
+ });
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ const response = await fetch(`/api/admin/board/${params.id}`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ ...formData,
+ role: formData.roleType === 'member' && (!formData.role || formData.role.trim() === '')
+ ? null // Explicitly send null for empty roles on members
+ : formData.role,
+ socials: formData.socials ? formData.socials : '', // Send as string
+ photoBase64: formData.photoBase64 || '', // Send base64 image
+ }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+
+ // Handle detailed validation errors from server
+ if (errorData.details) {
+ setErrors(errorData.details);
+ toast({
+ title: 'Validation Error',
+ description: errorData.error || 'Please fix the errors and try again.',
+ variant: 'destructive',
+ });
+ return;
+ }
+
+ throw new Error(errorData.error || 'Failed to update board member');
+ }
+
+ const data = await response.json();
+
+ // Clear auto-saved data and reset change tracking on successful submission
+ clearSavedData();
+ setHasUserMadeChanges(false);
+
+ toast({
+ title: "Board member updated",
+ description: `${formData.name} has been updated successfully.`,
+ });
+
+ router.push('/admin/board');
+ } catch (error: any) {
+ console.error('Error updating board member:', error);
+ toast({
+ title: "Error",
+ description: error.message || "Failed to update board member. Please try again.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleDelete = async () => {
+ setIsDeleting(true);
+
+ try {
+ const response = await fetch(`/api/admin/board/${params.id}`, {
+ method: 'DELETE',
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to delete board member');
+ }
+
+ toast({
+ title: "Board member deleted",
+ description: `${member?.name} has been deleted successfully.`,
+ });
+
+ router.push('/admin/board');
+ } catch (error: any) {
+ console.error('Error deleting board member:', error);
+ toast({
+ title: "Error",
+ description: "Failed to delete board member. Please try again.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ const parseSocials = (): { [key: string]: string } => {
+ if (!formData.socials || formData.socials.trim() === '') return {};
+ try {
+ const parsed = JSON.parse(formData.socials);
+ // Ensure it's an object and not null or array
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
+ return parsed;
+ }
+ return {};
+ } catch {
+ return {};
+ }
+ };
+
+ if (isLoadingMember) {
+ return (
+
+
+
+
+ {[1, 2].map((i) => (
+
+
+
+
+
+ ))}
+
+
+
+
+ );
+ }
+
+ if (!member) {
+ return null;
+ }
+
+ return (
+
+ {/* Page Header */}
+
+
+
+
+
+ Back to Board Members
+
+
+
+
Edit Board Member
+
+ Update {member.name}'s profile
+
+
+
+
+
+ {hasSavedData() && (
+
{
+ const draftData = restoreSavedData();
+ if (draftData) {
+ setFormData(draftData);
+ setHasUserMadeChanges(true);
+ toast({
+ title: 'Draft restored',
+ description: 'Your previous changes have been restored.',
+ duration: 2000,
+ });
+ }
+ }}
+ className="flex items-center gap-2"
+ >
+
+ Restore Draft
+
+ )}
+
+
+
+
+
+ Delete Member
+
+
+
+
+ Delete Board Member
+
+ Are you sure you want to delete "{member.name}"? This action cannot be undone.
+
+
+
+ Cancel
+
+ {isDeleting ? 'Deleting...' : 'Delete'}
+
+
+
+
+
+
+
+
+ {/* Main Form */}
+
+
+ {/* Preview Sidebar */}
+
+
+
+ Preview
+
+ How this member will appear on the board page
+
+
+
+
+
+
+
+
+
+ {formData.name || 'Member Name'}
+
+
+ {formData.role || 'Role'}
+
+ {formData.bio && (
+
+ {formData.bio}
+
+ )}
+
+
+ {formData.socials && (
+
+
Social Links:
+
+ {(() => {
+ const socials = parseSocials();
+ const entries = Object.entries(socials);
+ if (entries.length === 0 && formData.socials.trim() !== '') {
+ return (
+
+ Invalid JSON format
+
+ );
+ }
+ return entries.map(([platform, url]) => (
+
+ {platform}: {url}
+
+ ));
+ })()}
+
+
+ )}
+
+
+
+ Status: {formData.active ? 'Active' : 'Inactive'}
+
+
+
+
+
+
+
+ {/* Member Info Card */}
+
+
+ Member Info
+
+
+
+ Created: {' '}
+ {new Date(member.createdAt).toLocaleDateString()}
+
+
+ Last Updated: {' '}
+ {new Date(member.updatedAt).toLocaleDateString()}
+
+
+ ID: {' '}
+ {member.id}
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/app/admin/board/new/page.tsx b/frontend/app/admin/board/new/page.tsx
new file mode 100644
index 0000000..39d484c
--- /dev/null
+++ b/frontend/app/admin/board/new/page.tsx
@@ -0,0 +1,528 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+import { ArrowLeft, Save, X, RotateCcw } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { useToast } from "@/hooks/use-toast";
+import { useAutoSave } from '@/hooks/use-autosave';
+import { validateBoardMember } from '@/lib/validation';
+import ErrorBoundary from '@/components/ui/error-boundary';
+import Avatar from '@/components/ui/avatar';
+import ImageUpload from '@/components/ui/image-upload';
+import { FormField } from '@/components/ui/form-field';
+
+interface FormData {
+ name: string;
+ role: string;
+ roleType: 'board_member' | 'coordinator' | 'member';
+ photoUrl: string;
+ photoBase64: string;
+ bio: string;
+ socials: string;
+ active: boolean;
+}
+
+interface FormErrors {
+ name?: string;
+ role?: string;
+ roleType?: string;
+ photoUrl?: string;
+ bio?: string;
+ socials?: string;
+}
+
+export default function NewBoardMemberPage() {
+ const router = useRouter();
+ const { toast } = useToast();
+ const [isLoading, setIsLoading] = useState(false);
+ const [errors, setErrors] = useState({});
+ const [isSubmitted, setIsSubmitted] = useState(false);
+
+ const [formData, setFormData] = useState({
+ name: '',
+ role: '',
+ roleType: 'member',
+ photoUrl: '',
+ photoBase64: '',
+ bio: '',
+ socials: '{"email": "", "linkedin": "", "github": ""}',
+ active: true,
+ });
+
+ // Track if user has made changes (for new forms, any non-empty content counts as changes)
+ const [hasUserMadeChanges, setHasUserMadeChanges] = useState(false);
+
+ // Auto-save functionality - only enabled after user makes changes
+ const { restoreSavedData, clearSavedData, hasSavedData } = useAutoSave({
+ key: 'new-board-member',
+ data: formData,
+ enabled: !isLoading && hasUserMadeChanges,
+ onRestore: (data) => setFormData(data),
+ });
+
+ // Check for saved data on component mount
+ useEffect(() => {
+ if (hasSavedData()) {
+ toast({
+ title: 'Unsaved changes found',
+ description: 'Would you like to restore your previous draft?',
+ action: (
+ restoreSavedData()}
+ >
+ Restore
+
+ ),
+ duration: 10000,
+ });
+ }
+ }, []);
+
+ // Real-time form validation
+ const validateFormInRealTime = () => {
+ if (!isSubmitted) return;
+ const validation = validateBoardMember(formData);
+ setErrors(validation.errors);
+ };
+
+ useEffect(() => {
+ validateFormInRealTime();
+ }, [formData, isSubmitted]);
+
+ // Detect changes for new form (any meaningful content)
+ useEffect(() => {
+ if (!hasUserMadeChanges) {
+ const hasContent = Object.values(formData).some(value =>
+ value !== null && value !== undefined && String(value).trim() !== '' &&
+ value !== 0 && value !== true // ignore default values
+ );
+
+ if (hasContent) {
+ setHasUserMadeChanges(true);
+ }
+ }
+ }, [formData, hasUserMadeChanges]);
+
+ const isValidUrl = (url: string): boolean => {
+ try {
+ new URL(url);
+ return true;
+ } catch {
+ return false;
+ }
+ };
+
+ const isValidEmail = (email: string): boolean => {
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ return emailRegex.test(email);
+ };
+
+ const isValidSocialValue = (value: string): boolean => {
+ return isValidUrl(value) || isValidEmail(value);
+ };
+
+ const validateFormData = (): boolean => {
+ setIsSubmitted(true);
+ const validation = validateBoardMember(formData);
+ setErrors(validation.errors);
+ return validation.isValid;
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!validateFormData()) {
+ toast({
+ title: 'Validation Error',
+ description: 'Please fix the errors above before submitting.',
+ variant: 'destructive',
+ });
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ const response = await fetch('/api/admin/board', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ ...formData,
+ role: formData.roleType === 'member' && (!formData.role || formData.role.trim() === '')
+ ? null // Explicitly send null for empty roles on members
+ : formData.role,
+ socials: formData.socials ? formData.socials : '', // Send as string
+ photoBase64: formData.photoBase64 || '', // Send base64 image
+ displayOrder: 0, // Default display order, will be managed by drag-and-drop
+ }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+
+ // Handle detailed validation errors from server
+ if (errorData.details) {
+ setErrors(errorData.details);
+ toast({
+ title: 'Validation Error',
+ description: errorData.error || 'Please fix the errors and try again.',
+ variant: 'destructive',
+ });
+ return;
+ }
+
+ throw new Error(errorData.error || 'Failed to create board member');
+ }
+
+ const data = await response.json();
+
+ // Clear auto-saved data on successful submission
+ clearSavedData();
+
+ toast({
+ title: "Board member created",
+ description: `${formData.name} has been added to the board.`,
+ });
+
+ router.push('/admin/board');
+ } catch (error: any) {
+ console.error('Error creating board member:', error);
+ toast({
+ title: "Error",
+ description: error.message || "Failed to create board member. Please try again.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const parseSocials = (): { [key: string]: string } => {
+ if (!formData.socials || formData.socials.trim() === '') return {};
+ try {
+ const parsed = JSON.parse(formData.socials);
+ // Ensure it's an object and not null or array
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
+ return parsed;
+ }
+ return {};
+ } catch {
+ return {};
+ }
+ };
+
+ return (
+
+ {/* Page Header */}
+
+
+
+
+
+ Back to Board Members
+
+
+
+
Add Board Member
+
+ Create a new board member profile
+
+
+
+
+ {hasSavedData() && (
+
restoreSavedData()}
+ className="flex items-center gap-2"
+ >
+
+ Restore Draft
+
+ )}
+
+
+
+ {/* Main Form */}
+
+
+
+
+ Basic Information
+
+ Essential details about the board member
+
+
+
+
+ setFormData(prev => ({ ...prev, name: value }))}
+ placeholder="Full name"
+ required
+ error={errors.name}
+ maxLength={100}
+ hint="Full name of the board member"
+ />
+
+ setFormData(prev => ({ ...prev, role: value }))}
+ placeholder={formData.roleType === 'member' ? "e.g., Student, Researcher (optional)" : "e.g., President, Secretary"}
+ required={formData.roleType !== 'member'}
+ error={errors.role}
+ maxLength={100}
+ hint={formData.roleType === 'member'
+ ? "Optional role or description for regular members"
+ : "Position or role within the organization"
+ }
+ />
+
+
+ {/* Role Type */}
+
+
Member Type
+
+ setFormData(prev => ({ ...prev, roleType: value }))
+ }
+ >
+
+
+
+
+ Board Member
+ Coordinator
+ Member
+
+
+ {errors.roleType && (
+
{errors.roleType}
+ )}
+
+ Choose the type of membership for this person
+
+
+
+ {/* Photo Upload Section */}
+
+
Photo
+
+ {/* Image Upload */}
+
+
Upload Photo
+
setFormData(prev => ({ ...prev, photoBase64: base64 || '' }))}
+ cropSize={200}
+ maxSize={5}
+ />
+
+ Recommended: Upload for best quality
+
+
+
+ {/* URL Input */}
+
+
Or use Photo URL
+
setFormData(prev => ({ ...prev, photoUrl: e.target.value }))}
+ placeholder="https://example.com/photo.jpg"
+ className={errors.photoUrl ? "border-red-500" : ""}
+ />
+ {errors.photoUrl && (
+
{errors.photoUrl}
+ )}
+
+ Alternative: External image URL
+
+
+
+
+ 📸 Uploaded photos take priority over URLs and are stored securely in the database.
+
+
+
+ setFormData(prev => ({ ...prev, bio: value }))}
+ placeholder="Brief biography or description of the member's background and role"
+ rows={4}
+ error={errors.bio}
+ hint="Optional: Brief description of background, expertise, and responsibilities"
+ />
+
+
+
+
+
+ Social Links & Settings
+
+ Social media links and display settings
+
+
+
+
+
Social Links
+
setFormData(prev => ({ ...prev, socials: e.target.value }))}
+ placeholder='{"email": "user@example.com", "linkedin": "https://linkedin.com/in/...", "github": "https://github.com/..."}'
+ rows={3}
+ className={errors.socials ? "border-red-500" : ""}
+ />
+ {errors.socials && (
+ {errors.socials}
+ )}
+
+ JSON object of social links: {`{"email": "user@example.com", "linkedin": "https://...", "github": "https://..."}`}
+
+
+
+
+
Status
+
+
+ setFormData(prev => ({ ...prev, active: checked as boolean }))
+ }
+ />
+
+ Active member
+
+
+
+ Only active members appear on the public board page
+
+
+
+
+
+ {/* Form Actions */}
+
+ router.push('/admin/board')}
+ >
+ Cancel
+
+
+ {isLoading ? (
+ <>
+
+ Creating...
+ >
+ ) : (
+ <>
+
+ Create Member
+ >
+ )}
+
+
+
+
+
+ {/* Preview Sidebar */}
+
+
+
+ Preview
+
+ How this member will appear on the board page
+
+
+
+
+
+
+
+
+
+ {formData.name || 'Member Name'}
+
+
+ {formData.role || 'Role'}
+
+ {formData.bio && (
+
+ {formData.bio}
+
+ )}
+
+
+ {formData.socials && (
+
+
Social Links:
+
+ {(() => {
+ const socials = parseSocials();
+ const entries = Object.entries(socials);
+ if (entries.length === 0 && formData.socials.trim() !== '') {
+ return (
+
+ Invalid JSON format
+
+ );
+ }
+ return entries.map(([platform, url]) => (
+
+ {platform}: {url}
+
+ ));
+ })()}
+
+
+ )}
+
+
+
+ Status: {formData.active ? 'Active' : 'Inactive'}
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/app/admin/board/page.tsx b/frontend/app/admin/board/page.tsx
new file mode 100644
index 0000000..8d10aba
--- /dev/null
+++ b/frontend/app/admin/board/page.tsx
@@ -0,0 +1,786 @@
+'use client';
+
+import { useState, useEffect, Suspense } from 'react';
+import Link from 'next/link';
+import { Plus, Eye, Search, Filter, Trash2, Edit, MoreVertical } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Badge } from '@/components/ui/badge';
+import { useToast } from "@/hooks/use-toast";
+import { BoardMember } from '@/lib/types';
+import Avatar from '@/components/ui/avatar';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { SortableList } from '@/components/ui/sortable-list';
+
+interface BoardMembersTableProps {
+ members: BoardMember[];
+ onMembersChange: () => void;
+}
+
+function MemberStatusBadge({ active }: { active: boolean }) {
+ return (
+
+ {active ? 'Active' : 'Inactive'}
+
+ );
+}
+
+function MemberRoleTypeBadge({ roleType }: { roleType: 'board_member' | 'coordinator' | 'member' }) {
+ const getBadgeStyle = () => {
+ switch (roleType) {
+ case 'board_member':
+ return 'bg-blue-100 text-blue-800';
+ case 'coordinator':
+ return 'bg-purple-100 text-purple-800';
+ case 'member':
+ return 'bg-green-100 text-green-800';
+ default:
+ return 'bg-gray-100 text-gray-800';
+ }
+ };
+
+ const getDisplayName = () => {
+ switch (roleType) {
+ case 'board_member':
+ return 'Board Member';
+ case 'coordinator':
+ return 'Coordinator';
+ case 'member':
+ return 'Member';
+ default:
+ return roleType;
+ }
+ };
+
+ return (
+
+ {getDisplayName()}
+
+ );
+}
+
+function BoardMembersTable({ members, onMembersChange }: BoardMembersTableProps) {
+ const { toast } = useToast();
+ const [selectedMembers, setSelectedMembers] = useState>(new Set());
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [memberToDelete, setMemberToDelete] = useState(null);
+ const [isBulkDeleting, setIsBulkDeleting] = useState(false);
+ const [isBulkUpdating, setIsBulkUpdating] = useState(false);
+
+ const handleSelectMember = (memberId: string, checked: boolean) => {
+ const newSelected = new Set(selectedMembers);
+ if (checked) {
+ newSelected.add(memberId);
+ } else {
+ newSelected.delete(memberId);
+ }
+ setSelectedMembers(newSelected);
+ };
+
+ const handleSelectAll = (checked: boolean) => {
+ if (checked) {
+ setSelectedMembers(new Set(members.map(m => m.id)));
+ } else {
+ setSelectedMembers(new Set());
+ }
+ };
+
+ const handleReorder = async (reorderedMembers: BoardMember[]) => {
+ try {
+ // Create updates array with new display orders
+ const updates = reorderedMembers.map((member, index) => ({
+ id: member.id,
+ displayOrder: index + 1
+ }));
+
+ const response = await fetch('/api/admin/board/bulk', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ action: 'updateDisplayOrder',
+ memberIds: updates.map(u => u.id),
+ data: { updates }
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to update member order');
+ }
+
+ toast({
+ title: "Success",
+ description: "Member order updated successfully.",
+ });
+
+ // Only refresh data from server after successful save
+ onMembersChange();
+ } catch (error) {
+ console.error('Error updating member order:', error);
+ toast({
+ title: "Error",
+ description: "Failed to update member order. Please try again.",
+ variant: "destructive",
+ });
+ throw error; // Re-throw to let SortableList handle the revert
+ }
+ };
+
+ const handleDeleteMember = async (memberId: string) => {
+ try {
+ setIsDeleting(true);
+ const response = await fetch(`/api/admin/board/${memberId}`, {
+ method: 'DELETE',
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to delete member');
+ }
+
+ toast({
+ title: "Success",
+ description: "Board member deleted successfully.",
+ });
+
+ onMembersChange();
+ } catch (error) {
+ console.error('Error deleting member:', error);
+ toast({
+ title: "Error",
+ description: "Failed to delete member. Please try again.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsDeleting(false);
+ setMemberToDelete(null);
+ }
+ };
+
+ const handleBulkDelete = async () => {
+ try {
+ setIsBulkDeleting(true);
+ const response = await fetch('/api/admin/board/bulk', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ action: 'delete',
+ memberIds: Array.from(selectedMembers),
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to delete members');
+ }
+
+ const result = await response.json();
+
+ if (result.errors && result.errors.length > 0) {
+ toast({
+ title: "Partial Success",
+ description: `${result.success.length} members deleted, but ${result.errors.length} failed.`,
+ variant: "destructive",
+ });
+ } else {
+ toast({
+ title: "Success",
+ description: `${selectedMembers.size} members deleted successfully.`,
+ });
+ }
+
+ setSelectedMembers(new Set());
+ onMembersChange();
+ } catch (error) {
+ console.error('Error deleting members:', error);
+ toast({
+ title: "Error",
+ description: "Failed to delete members. Please try again.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsBulkDeleting(false);
+ }
+ };
+
+ const handleBulkStatusUpdate = async (active: boolean) => {
+ try {
+ setIsBulkUpdating(true);
+ const response = await fetch('/api/admin/board/bulk', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ action: 'updateStatus',
+ memberIds: Array.from(selectedMembers),
+ data: { active },
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to update member status');
+ }
+
+ const result = await response.json();
+
+ if (result.errors && result.errors.length > 0) {
+ toast({
+ title: "Partial Success",
+ description: `Updated ${result.success.length} members, ${result.errors.length} failed.`,
+ variant: "destructive",
+ });
+ } else {
+ toast({
+ title: "Success",
+ description: `Updated ${result.success.length} members to ${active ? 'active' : 'inactive'}.`,
+ });
+ }
+
+ setSelectedMembers(new Set());
+ onMembersChange();
+ } catch (error) {
+ console.error('Error updating member status:', error);
+ toast({
+ title: "Error",
+ description: "Failed to update member status. Please try again.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsBulkUpdating(false);
+ }
+ };
+
+ const handleBulkRoleTypeUpdate = async (roleType: 'board_member' | 'coordinator' | 'member') => {
+ try {
+ setIsBulkUpdating(true);
+ const response = await fetch('/api/admin/board/bulk', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ action: 'updateRoleType',
+ memberIds: Array.from(selectedMembers),
+ data: { roleType },
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to update member role type');
+ }
+
+ const result = await response.json();
+
+ if (result.errors && result.errors.length > 0) {
+ toast({
+ title: "Partial Success",
+ description: `Updated ${result.success.length} members, ${result.errors.length} failed.`,
+ variant: "destructive",
+ });
+ } else {
+ const roleTypeNames = {
+ 'board_member': 'Board Member',
+ 'coordinator': 'Coordinator',
+ 'member': 'Member'
+ };
+ toast({
+ title: "Success",
+ description: `Updated ${result.success.length} members to ${roleTypeNames[roleType]}.`,
+ });
+ }
+
+ setSelectedMembers(new Set());
+ onMembersChange();
+ } catch (error) {
+ console.error('Error updating member role type:', error);
+ toast({
+ title: "Error",
+ description: "Failed to update member role type. Please try again.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsBulkUpdating(false);
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+ Board Members
+
+ Manage your board members and coordinators. Drag and drop to reorder.
+
+
+
+ {selectedMembers.size > 0 && (
+ <>
+
+
+
+ Update Status ({selectedMembers.size})
+
+
+
+ handleBulkStatusUpdate(true)}>
+ Set to Active
+
+ handleBulkStatusUpdate(false)}>
+ Set to Inactive
+
+
+
+
+
+
+
+ Change Role ({selectedMembers.size})
+
+
+
+ handleBulkRoleTypeUpdate('board_member')}>
+ Set to Board Member
+
+ handleBulkRoleTypeUpdate('coordinator')}>
+ Set to Coordinator
+
+ handleBulkRoleTypeUpdate('member')}>
+ Set to Member
+
+
+
+
+
+
+
+
+ Delete ({selectedMembers.size})
+
+
+
+
+ Delete Board Members
+
+ Are you sure you want to delete {selectedMembers.size} selected members?
+ This action cannot be undone.
+
+
+
+ Cancel
+
+ Delete Members
+
+
+
+
+ >
+ )}
+
+
+
+
+ New Member
+
+
+
+
+
+
+ {members.length === 0 ? (
+
+
No board members found.
+
+
+
+ Create your first member
+
+
+
+ ) : (
+
+ {/* Select All */}
+
+ 0}
+ onCheckedChange={handleSelectAll}
+ />
+
+ Select All ({members.length} members)
+
+
+
+ {/* Sortable Members List */}
+
member.id}
+ className="space-y-2"
+ >
+ {(member) => (
+
+
+ handleSelectMember(member.id, checked as boolean)
+ }
+ />
+
+
+
+
+
{member.name}
+
{member.role}
+ {member.bio && (
+
+ {member.bio}
+
+ )}
+
+
+
+
+
+
+
+ {new Date(member.updatedAt).toLocaleDateString()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Edit
+
+
+
+ setMemberToDelete(member.id)}
+ className="text-red-600"
+ >
+
+ Delete
+
+
+
+
+
+ )}
+
+
+ )}
+
+
+
+ {/* Delete Confirmation Dialog */}
+ setMemberToDelete(null)}>
+
+
+ Delete Board Member
+
+ Are you sure you want to delete this board member? This action cannot be undone.
+
+
+
+ Cancel
+ memberToDelete && handleDeleteMember(memberToDelete)}
+ disabled={isDeleting}
+ className="bg-red-600 hover:bg-red-700"
+ >
+ {isDeleting ? 'Deleting...' : 'Delete Member'}
+
+
+
+
+ >
+ );
+}
+
+function BoardMembersManager() {
+ const [members, setMembers] = useState([]);
+ const [filteredMembers, setFilteredMembers] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [searchTerm, setSearchTerm] = useState('');
+ const [statusFilter, setStatusFilter] = useState('all');
+ const [roleFilter, setRoleFilter] = useState('all');
+ const { toast } = useToast();
+
+ const loadMembers = async () => {
+ try {
+ setIsLoading(true);
+ const response = await fetch('/api/admin/board');
+
+ if (!response.ok) {
+ throw new Error('Failed to load board members');
+ }
+
+ const data = await response.json();
+ const sortedMembers = data.members.sort((a: BoardMember, b: BoardMember) => {
+ if (a.displayOrder !== b.displayOrder) {
+ return a.displayOrder - b.displayOrder;
+ }
+ return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
+ });
+
+ setMembers(sortedMembers);
+ } catch (error) {
+ console.error('Error loading board members:', error);
+ toast({
+ title: "Error",
+ description: "Failed to load board members. Please refresh the page.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadMembers();
+ }, []);
+
+ // Apply filters whenever members, searchTerm, statusFilter, or roleFilter changes
+ useEffect(() => {
+ let filtered = members;
+
+ // Apply search filter
+ if (searchTerm) {
+ const search = searchTerm.toLowerCase();
+ filtered = filtered.filter(member =>
+ member.name.toLowerCase().includes(search) ||
+ (member.role && member.role.toLowerCase().includes(search)) ||
+ (member.bio && member.bio.toLowerCase().includes(search))
+ );
+ }
+
+ // Apply status filter
+ if (statusFilter !== 'all') {
+ filtered = filtered.filter(member =>
+ statusFilter === 'active' ? member.active : !member.active
+ );
+ }
+
+ // Apply role filter
+ if (roleFilter !== 'all') {
+ filtered = filtered.filter(member => member.roleType === roleFilter);
+ }
+
+ setFilteredMembers(filtered);
+ }, [members, searchTerm, statusFilter, roleFilter]);
+
+ if (isLoading) {
+ return ;
+ }
+
+ // Calculate stats by member type
+ const boardMembers = members.filter(m => m.roleType === 'board_member');
+ const coordinators = members.filter(m => m.roleType === 'coordinator');
+ const regularMembers = members.filter(m => m.roleType === 'member');
+ const activeMembers = members.filter(m => m.active).length;
+
+ return (
+
+ {/* Page Header */}
+
+
+
Team Members
+
+ Manage your club's board members, coordinators, and team members
+
+
+
+
+
+ Add Member
+
+
+
+
+ {/* Stats Cards */}
+
+
+
+ Board Members
+
+
+ {boardMembers.filter(m => m.active).length}
+ {boardMembers.length} total
+
+
+
+
+ Coordinators
+
+
+ {coordinators.filter(m => m.active).length}
+ {coordinators.length} total
+
+
+
+
+ Members
+
+
+ {regularMembers.filter(m => m.active).length}
+ {regularMembers.length} total
+
+
+
+
+ Total Team
+
+
+ {activeMembers}
+ {members.length} total
+
+
+
+
+ {/* Filters and Search */}
+
+
+ Filter Members
+
+ Search and filter your board members
+
+
+
+
+
+ setSearchTerm(e.target.value)}
+ className="max-w-sm"
+ />
+
+
+
+
+
+
+ All Status
+ Active
+ Inactive
+
+
+
+
+
+
+
+ All Roles
+ Board Member
+ Coordinator
+ Member
+
+
+
+ {(searchTerm || statusFilter !== 'all' || roleFilter !== 'all') && (
+
+ Showing {filteredMembers.length} of {members.length} members
+
+ )}
+
+
+
+ {/* Board Members Table */}
+
+
+ );
+}
+
+function BoardMembersLoadingSkeleton() {
+ return (
+
+
+
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+
+
+
+
+ );
+}
+
+export default function AdminBoardPage() {
+ return ;
+}
\ No newline at end of file
diff --git a/frontend/app/admin/layout.tsx b/frontend/app/admin/layout.tsx
new file mode 100644
index 0000000..e8e111b
--- /dev/null
+++ b/frontend/app/admin/layout.tsx
@@ -0,0 +1,145 @@
+import type { Metadata } from 'next';
+import Link from 'next/link';
+import {
+ Users,
+ FolderOpen,
+ BarChart3,
+ Settings,
+ LogOut,
+ Menu,
+ X,
+ Handshake
+} from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Toaster } from '@/components/ui/toaster';
+
+export const metadata: Metadata = {
+ title: 'Admin Dashboard - Gradient Science Club',
+ description: 'Content management system for Gradient Science Club website',
+ robots: 'noindex, nofollow', // Don't index admin pages
+};
+
+interface AdminLayoutProps {
+ children: React.ReactNode;
+}
+
+const navigation = [
+ {
+ name: 'Dashboard',
+ href: '/admin',
+ icon: BarChart3,
+ },
+ {
+ name: 'Projects',
+ href: '/admin/projects',
+ icon: FolderOpen,
+ },
+ {
+ name: 'Board Members',
+ href: '/admin/board',
+ icon: Users,
+ },
+ {
+ name: 'Partnerships',
+ href: '/admin/partnerships',
+ icon: Handshake,
+ },
+ {
+ name: 'Settings',
+ href: '/admin/settings',
+ icon: Settings,
+ },
+];
+
+function AdminSidebar({ className }: { className?: string }) {
+ return (
+
+ {navigation.map((item) => (
+
+
+ {item.name}
+
+ ))}
+
+ );
+}
+
+function MobileNavigation() {
+ return (
+
+ );
+}
+
+function AdminHeader() {
+ return (
+
+ );
+}
+
+export default function AdminLayout({ children }: AdminLayoutProps) {
+ return (
+
+
+
+ {/* Mobile Navigation */}
+
+
+
+ {/* Desktop Sidebar */}
+
+
+ {/* Main Content */}
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx
new file mode 100644
index 0000000..ca11cc8
--- /dev/null
+++ b/frontend/app/admin/page.tsx
@@ -0,0 +1,337 @@
+import { Suspense } from 'react';
+import Link from 'next/link';
+import {
+ Users,
+ FolderOpen,
+ Activity,
+ TrendingUp,
+ Plus,
+ Eye,
+ Edit,
+ Calendar,
+ UserCheck,
+ User,
+ Users2,
+ Handshake
+} from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { projectsRepo, boardMembersRepo } from '@/lib/repositories';
+import { partnershipsRepo } from '@/lib/repositories/partnerships';
+
+interface StatsCardProps {
+ title: string;
+ value: string | number;
+ description: string;
+ icon: React.ComponentType<{ className?: string }>;
+ trend?: {
+ value: string;
+ positive: boolean;
+ };
+}
+
+function StatsCard({ title, value, description, icon: Icon, trend }: StatsCardProps) {
+ return (
+
+
+ {title}
+
+
+
+ {value}
+ {description}
+ {trend && (
+
+
+ {trend.value}
+
+ )}
+
+
+ );
+}
+
+async function DashboardStats() {
+ try {
+ // Get all data in parallel
+ const [
+ allProjects,
+ activeProjects,
+ completedProjects,
+ memberStats,
+ allPartnerships,
+ activePartnerships
+ ] = await Promise.all([
+ projectsRepo.findAll(),
+ projectsRepo.findAll('active'),
+ projectsRepo.findAll('completed'),
+ boardMembersRepo.getStatsByRoleType(),
+ partnershipsRepo.findAll(),
+ partnershipsRepo.findActive()
+ ]);
+
+ const stats = [
+ {
+ title: 'Total Projects',
+ value: allProjects.length,
+ description: `${activeProjects.length} active, ${completedProjects.length} completed`,
+ icon: FolderOpen,
+ },
+ {
+ title: 'Board Members',
+ value: memberStats.boardMembers.active,
+ description: `${memberStats.boardMembers.total} total`,
+ icon: Users,
+ },
+ {
+ title: 'Coordinators',
+ value: memberStats.coordinators.active,
+ description: `${memberStats.coordinators.total} total`,
+ icon: UserCheck,
+ },
+ {
+ title: 'Members',
+ value: memberStats.members.active,
+ description: `${memberStats.members.total} total`,
+ icon: User,
+ },
+ {
+ title: 'Total Team',
+ value: memberStats.total.active,
+ description: `${memberStats.total.total} total members`,
+ icon: Users2,
+ },
+ {
+ title: 'Active Projects',
+ value: activeProjects.length,
+ description: 'Currently in progress',
+ icon: Activity,
+ },
+ {
+ title: 'Partnerships',
+ value: activePartnerships.length,
+ description: `${allPartnerships.length} total`,
+ icon: Handshake,
+ },
+ ];
+
+ return (
+
+ {stats.map((stat, index) => (
+
+ ))}
+
+ );
+ } catch (error) {
+ console.error('Error loading dashboard stats:', error);
+ return (
+
+
+
+ Error loading dashboard stats
+
+
+
+ );
+ }
+}
+
+async function RecentActivity() {
+ try {
+ const [recentProjects, recentBoardMembers] = await Promise.all([
+ projectsRepo.findAll(),
+ boardMembersRepo.findAll()
+ ]);
+
+ // Sort by updatedAt and take the most recent 5 items
+ const allItems = [
+ ...recentProjects.map(p => ({
+ type: 'project' as const,
+ title: p.title,
+ status: p.status,
+ updatedAt: p.updatedAt,
+ href: `/admin/projects`,
+ })),
+ ...recentBoardMembers.map(m => ({
+ type: 'member' as const,
+ title: m.name,
+ status: m.active ? 'active' : 'inactive',
+ updatedAt: m.updatedAt,
+ href: `/admin/board`,
+ })),
+ ].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
+ .slice(0, 5);
+
+ return (
+
+
+ Recent Activity
+ Latest updates to your content
+
+
+ {allItems.length === 0 ? (
+ No recent activity
+ ) : (
+
+ {allItems.map((item, index) => (
+
+
+ {item.type === 'project' ? (
+
+ ) : (
+
+ )}
+
+
{item.title}
+
+ {item.type === 'project' ? 'Project' : 'Board Member'} • {item.status}
+
+
+
+
+ {new Date(item.updatedAt).toLocaleDateString()}
+
+
+ ))}
+
+ )}
+
+
+ );
+ } catch (error) {
+ console.error('Error loading recent activity:', error);
+ return (
+
+
+ Error loading recent activity
+
+
+ );
+ }
+}
+
+function QuickActions() {
+ const actions = [
+ {
+ title: 'Add New Project',
+ description: 'Create a new club project',
+ href: '/admin/projects/new',
+ icon: FolderOpen,
+ color: 'bg-blue-500 hover:bg-blue-600',
+ },
+ {
+ title: 'Add Board Member',
+ description: 'Add a new board member',
+ href: '/admin/board/new',
+ icon: Users,
+ color: 'bg-green-500 hover:bg-green-600',
+ },
+ {
+ title: 'View Website',
+ description: 'See the public website',
+ href: '/',
+ icon: Eye,
+ color: 'bg-purple-500 hover:bg-purple-600',
+ external: true,
+ },
+ {
+ title: 'Site Settings',
+ description: 'Configure site settings',
+ href: '/admin/settings',
+ icon: Edit,
+ color: 'bg-gray-500 hover:bg-gray-600',
+ },
+ ];
+
+ return (
+
+
+ Quick Actions
+ Common tasks and shortcuts
+
+
+
+ {actions.map((action, index) => (
+
+
+
+
+
{action.title}
+
+ {action.description}
+
+
+
+
+ ))}
+
+
+
+ );
+}
+
+function StatsLoadingSkeleton() {
+ return (
+
+ {[1, 2, 3, 4].map((i) => (
+
+
+
+
+
+ ))}
+
+ );
+}
+
+export default function AdminDashboard() {
+ return (
+
+ {/* Page Header */}
+
+
+
Dashboard
+
+ Welcome back! Here's what's happening with your website.
+
+
+
+
+
+
+ View Site
+
+
+
+
+
+ {/* Stats Cards */}
+
}>
+
+
+
+ {/* Content Grid */}
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/app/admin/partnerships/[id]/edit/page.tsx b/frontend/app/admin/partnerships/[id]/edit/page.tsx
new file mode 100644
index 0000000..21b5eeb
--- /dev/null
+++ b/frontend/app/admin/partnerships/[id]/edit/page.tsx
@@ -0,0 +1,272 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+import { ArrowLeft, Save, Loader2 } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Label } from '@/components/ui/label';
+import { Checkbox } from '@/components/ui/checkbox';
+import ImageUpload from '@/components/ui/image-upload';
+import { useToast } from '@/hooks/use-toast';
+import type { PartnershipInput } from '@/lib/types';
+
+interface FormErrors {
+ name?: string;
+ yearFrom?: string;
+ yearTo?: string;
+}
+
+export default function EditPartnershipPage({ params }: { params: { id: string } }) {
+ const router = useRouter();
+ const { toast } = useToast();
+ const [isLoading, setIsLoading] = useState(false);
+ const [isFetching, setIsFetching] = useState(true);
+ const [errors, setErrors] = useState({});
+ const currentYear = new Date().getFullYear();
+
+ const [formData, setFormData] = useState({
+ name: '',
+ websiteUrl: '',
+ logoUrl: '',
+ logoBase64: '',
+ yearFrom: currentYear,
+ yearTo: undefined,
+ active: true,
+ });
+
+ useEffect(() => {
+ fetchPartnership();
+ }, []);
+
+ const fetchPartnership = async () => {
+ try {
+ const response = await fetch(`/api/admin/partnerships/${params.id}`);
+ if (!response.ok) throw new Error('Failed to fetch partnership');
+ const { partnership } = await response.json();
+ setFormData({
+ name: partnership.name,
+ websiteUrl: partnership.websiteUrl || '',
+ logoUrl: partnership.logoUrl || '',
+ logoBase64: partnership.logoBase64 || '',
+ yearFrom: partnership.yearFrom,
+ yearTo: partnership.yearTo,
+ active: partnership.active,
+ });
+ } catch (error) {
+ console.error('Error fetching partnership:', error);
+ toast({
+ title: 'Error',
+ description: 'Failed to load partnership',
+ variant: 'destructive',
+ });
+ } finally {
+ setIsFetching(false);
+ }
+ };
+
+ const validateForm = (): boolean => {
+ const newErrors: FormErrors = {};
+
+ if (!formData.name.trim()) {
+ newErrors.name = 'Organization name is required';
+ }
+
+ if (!formData.yearFrom) {
+ newErrors.yearFrom = 'Start year is required';
+ } else if (formData.yearFrom < 1900 || formData.yearFrom > 2200) {
+ newErrors.yearFrom = 'Please enter a valid year';
+ }
+
+ if (formData.yearTo) {
+ if (formData.yearTo < 1900 || formData.yearTo > 2200) {
+ newErrors.yearTo = 'Please enter a valid year';
+ } else if (formData.yearTo < formData.yearFrom) {
+ newErrors.yearTo = 'End year must be after start year';
+ }
+ }
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!validateForm()) {
+ toast({
+ title: 'Validation Error',
+ description: 'Please fix the errors before submitting.',
+ variant: 'destructive',
+ });
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ const response = await fetch(`/api/admin/partnerships/${params.id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(formData),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'Failed to update partnership');
+ }
+
+ toast({
+ title: 'Partnership Updated',
+ description: 'The partnership has been updated successfully.',
+ });
+
+ router.push('/admin/partnerships');
+ router.refresh();
+ } catch (error) {
+ console.error('Error updating partnership:', error);
+ toast({
+ title: 'Error',
+ description: error instanceof Error ? error.message : 'Failed to update partnership',
+ variant: 'destructive',
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ if (isFetching) {
+ return Loading...
;
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
Edit Partnership
+
Update partnership details
+
+
+
+
+ Back
+
+
+
+
+ {/* Form */}
+
+
+
+ Partnership Details
+ Update the details for this partnership
+
+
+ {/* Name */}
+
+
Organization Name *
+
setFormData({ ...formData, name: e.target.value })}
+ className={errors.name ? 'border-red-500' : ''}
+ />
+ {errors.name &&
{errors.name}
}
+
+
+ {/* Website URL */}
+
+
Website URL
+
setFormData({ ...formData, websiteUrl: e.target.value })}
+ placeholder="https://example.com"
+ />
+
Link to the organization's website
+
+
+ {/* Logo */}
+
+
Logo
+
{
+ if (value && value.startsWith('data:')) {
+ setFormData({ ...formData, logoBase64: value, logoUrl: '' });
+ } else if (value) {
+ setFormData({ ...formData, logoUrl: value, logoBase64: '' });
+ } else {
+ setFormData({ ...formData, logoBase64: '', logoUrl: '' });
+ }
+ }}
+ noCrop={true}
+ maxWidth={800}
+ maxHeight={400}
+ />
+ Upload organization logo (will be resized maintaining aspect ratio)
+
+
+ {/* Year Range */}
+
+
+
Start Year *
+
setFormData({ ...formData, yearFrom: parseInt(e.target.value) || currentYear })}
+ min={1900}
+ max={2200}
+ className={errors.yearFrom ? 'border-red-500' : ''}
+ />
+ {errors.yearFrom &&
{errors.yearFrom}
}
+
+
+
End Year (leave empty for ongoing)
+
setFormData({ ...formData, yearTo: e.target.value ? parseInt(e.target.value) : undefined })}
+ min={1900}
+ max={2200}
+ className={errors.yearTo ? 'border-red-500' : ''}
+ placeholder="Present"
+ />
+ {errors.yearTo &&
{errors.yearTo}
}
+
+
+
+ {/* Active Status */}
+
+ setFormData({ ...formData, active: !!checked })}
+ />
+ Active partnership (visible on website)
+
+
+
+
+ {/* Actions */}
+
+
+ Cancel
+
+
+ {isLoading && }
+
+ Save Changes
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/app/admin/partnerships/new/page.tsx b/frontend/app/admin/partnerships/new/page.tsx
new file mode 100644
index 0000000..17425d7
--- /dev/null
+++ b/frontend/app/admin/partnerships/new/page.tsx
@@ -0,0 +1,240 @@
+'use client';
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+import { ArrowLeft, Save, Loader2 } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Label } from '@/components/ui/label';
+import { Checkbox } from '@/components/ui/checkbox';
+import ImageUpload from '@/components/ui/image-upload';
+import { useToast } from '@/hooks/use-toast';
+import type { PartnershipInput } from '@/lib/types';
+
+interface FormErrors {
+ name?: string;
+ yearFrom?: string;
+ yearTo?: string;
+}
+
+export default function NewPartnershipPage() {
+ const router = useRouter();
+ const { toast } = useToast();
+ const [isLoading, setIsLoading] = useState(false);
+ const [errors, setErrors] = useState({});
+ const currentYear = new Date().getFullYear();
+
+ const [formData, setFormData] = useState({
+ name: '',
+ websiteUrl: '',
+ logoUrl: '',
+ logoBase64: '',
+ yearFrom: currentYear,
+ yearTo: undefined,
+ active: true,
+ });
+
+ const validateForm = (): boolean => {
+ const newErrors: FormErrors = {};
+
+ if (!formData.name.trim()) {
+ newErrors.name = 'Organization name is required';
+ }
+
+ if (!formData.yearFrom) {
+ newErrors.yearFrom = 'Start year is required';
+ } else if (formData.yearFrom < 1900 || formData.yearFrom > 2200) {
+ newErrors.yearFrom = 'Please enter a valid year';
+ }
+
+ if (formData.yearTo) {
+ if (formData.yearTo < 1900 || formData.yearTo > 2200) {
+ newErrors.yearTo = 'Please enter a valid year';
+ } else if (formData.yearTo < formData.yearFrom) {
+ newErrors.yearTo = 'End year must be after start year';
+ }
+ }
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!validateForm()) {
+ toast({
+ title: 'Validation Error',
+ description: 'Please fix the errors before submitting.',
+ variant: 'destructive',
+ });
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ const response = await fetch('/api/admin/partnerships', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ ...formData,
+ displayOrder: 0,
+ }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'Failed to create partnership');
+ }
+
+ toast({
+ title: 'Partnership Created',
+ description: 'The partnership has been created successfully.',
+ });
+
+ router.push('/admin/partnerships');
+ router.refresh();
+ } catch (error) {
+ console.error('Error creating partnership:', error);
+ toast({
+ title: 'Error',
+ description: error instanceof Error ? error.message : 'Failed to create partnership',
+ variant: 'destructive',
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
New Partnership
+
Add a new organization partnership
+
+
+
+
+ Back
+
+
+
+
+ {/* Form */}
+
+
+
+ Partnership Details
+ Fill in the details for the new partnership
+
+
+ {/* Name */}
+
+
Organization Name *
+
setFormData({ ...formData, name: e.target.value })}
+ className={errors.name ? 'border-red-500' : ''}
+ />
+ {errors.name &&
{errors.name}
}
+
+
+ {/* Website URL */}
+
+
Website URL
+
setFormData({ ...formData, websiteUrl: e.target.value })}
+ placeholder="https://example.com"
+ />
+
Link to the organization's website
+
+
+ {/* Logo */}
+
+
Logo
+
{
+ if (value && value.startsWith('data:')) {
+ setFormData({ ...formData, logoBase64: value, logoUrl: '' });
+ } else if (value) {
+ setFormData({ ...formData, logoUrl: value, logoBase64: '' });
+ } else {
+ setFormData({ ...formData, logoBase64: '', logoUrl: '' });
+ }
+ }}
+ noCrop={true}
+ maxWidth={800}
+ maxHeight={400}
+ />
+ Upload organization logo (will be resized maintaining aspect ratio)
+
+
+ {/* Year Range */}
+
+
+
Start Year *
+
setFormData({ ...formData, yearFrom: parseInt(e.target.value) || currentYear })}
+ min={1900}
+ max={2200}
+ className={errors.yearFrom ? 'border-red-500' : ''}
+ />
+ {errors.yearFrom &&
{errors.yearFrom}
}
+
+
+
End Year (leave empty for ongoing)
+
setFormData({ ...formData, yearTo: e.target.value ? parseInt(e.target.value) : undefined })}
+ min={1900}
+ max={2200}
+ className={errors.yearTo ? 'border-red-500' : ''}
+ placeholder="Present"
+ />
+ {errors.yearTo &&
{errors.yearTo}
}
+
+
+
+ {/* Active Status */}
+
+ setFormData({ ...formData, active: !!checked })}
+ />
+ Active partnership (visible on website)
+
+
+
+
+ {/* Actions */}
+
+
+ Cancel
+
+
+ {isLoading && }
+
+ Create Partnership
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/app/admin/partnerships/page.tsx b/frontend/app/admin/partnerships/page.tsx
new file mode 100644
index 0000000..59d8ba3
--- /dev/null
+++ b/frontend/app/admin/partnerships/page.tsx
@@ -0,0 +1,338 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import Link from 'next/link';
+import { Plus, Trash2, Edit, MoreVertical, GripVertical } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Badge } from '@/components/ui/badge';
+import { useToast } from "@/hooks/use-toast";
+import { Partnership } from '@/lib/types';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@/components/ui/alert-dialog';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { SortableList } from '@/components/ui/sortable-list';
+
+export default function PartnershipsAdminPage() {
+ const { toast } = useToast();
+ const [partnerships, setPartnerships] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [selectedPartnerships, setSelectedPartnerships] = useState>(new Set());
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [partnershipToDelete, setPartnershipToDelete] = useState(null);
+
+ useEffect(() => {
+ fetchPartnerships();
+ }, []);
+
+ const fetchPartnerships = async () => {
+ try {
+ const response = await fetch('/api/admin/partnerships');
+ if (!response.ok) throw new Error('Failed to fetch partnerships');
+ const data = await response.json();
+ setPartnerships(data.partnerships);
+ } catch (error) {
+ console.error('Error fetching partnerships:', error);
+ toast({
+ title: 'Error',
+ description: 'Failed to load partnerships',
+ variant: 'destructive',
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleReorder = async (reorderedPartnerships: Partnership[]) => {
+ try {
+ const updates = reorderedPartnerships.map((partnership, index) => ({
+ id: partnership.id,
+ displayOrder: index + 1
+ }));
+
+ const response = await fetch('/api/admin/partnerships/bulk', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ action: 'updateDisplayOrder',
+ partnershipIds: updates.map(u => u.id),
+ data: { updates }
+ }),
+ });
+
+ if (!response.ok) throw new Error('Failed to update order');
+
+ toast({
+ title: 'Success',
+ description: 'Partnership order updated successfully',
+ });
+ await fetchPartnerships();
+ } catch (error) {
+ console.error('Error saving order:', error);
+ toast({
+ title: 'Error',
+ description: 'Failed to save order',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const handleDelete = async (id: string) => {
+ try {
+ const response = await fetch(`/api/admin/partnerships/${id}`, {
+ method: 'DELETE',
+ });
+
+ if (!response.ok) throw new Error('Failed to delete partnership');
+
+ toast({
+ title: 'Success',
+ description: 'Partnership deleted successfully',
+ });
+ await fetchPartnerships();
+ } catch (error) {
+ console.error('Error deleting partnership:', error);
+ toast({
+ title: 'Error',
+ description: 'Failed to delete partnership',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const handleBulkDelete = async () => {
+ try {
+ const response = await fetch('/api/admin/partnerships/bulk', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ action: 'delete',
+ partnershipIds: Array.from(selectedPartnerships),
+ }),
+ });
+
+ if (!response.ok) throw new Error('Failed to delete partnerships');
+
+ toast({
+ title: 'Success',
+ description: `${selectedPartnerships.size} partnership(s) deleted successfully`,
+ });
+ setSelectedPartnerships(new Set());
+ await fetchPartnerships();
+ } catch (error) {
+ console.error('Error bulk deleting partnerships:', error);
+ toast({
+ title: 'Error',
+ description: 'Failed to delete partnerships',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const handleSelectAll = (checked: boolean) => {
+ if (checked) {
+ setSelectedPartnerships(new Set(partnerships.map(p => p.id)));
+ } else {
+ setSelectedPartnerships(new Set());
+ }
+ };
+
+ const handleSelectPartnership = (id: string, checked: boolean) => {
+ const newSelected = new Set(selectedPartnerships);
+ if (checked) {
+ newSelected.add(id);
+ } else {
+ newSelected.delete(id);
+ }
+ setSelectedPartnerships(newSelected);
+ };
+
+ const activeCount = partnerships.filter(p => p.active).length;
+ const totalCount = partnerships.length;
+
+ if (isLoading) {
+ return Loading...
;
+ }
+
+ return (
+
+ {/* Header */}
+
+
Partnerships
+
Manage your organization partnerships
+
+
+ {/* Stats */}
+
+
+
+ Total Partnerships
+
+
+ {totalCount}
+
+
+
+
+ Active
+
+
+ {activeCount}
+
+
+
+
+ Inactive
+
+
+ {totalCount - activeCount}
+
+
+
+
+ {/* Actions */}
+
+
+
+
+
+ Add Partnership
+
+
+
+ {selectedPartnerships.size > 0 && (
+
setDeleteDialogOpen(true)}
+ >
+
+ Delete Selected ({selectedPartnerships.size})
+
+ )}
+
+
+ {/* Partnerships List */}
+
+
+
+ All Partnerships
+ 0}
+ onCheckedChange={handleSelectAll}
+ />
+
+
+
+
+ items={partnerships}
+ onReorder={handleReorder}
+ getItemId={(partnership) => partnership.id}
+ >
+ {(partnership) => (
+
+
+
handleSelectPartnership(partnership.id, !!checked)}
+ />
+
+ {(partnership.logoBase64 || partnership.logoUrl) ? (
+
+ ) : (
+
+ {partnership.name.charAt(0)}
+
+ )}
+
+
+
{partnership.name}
+
+ {partnership.yearFrom} - {partnership.yearTo || 'Present'}
+
+
+
+ {partnership.active ? 'Active' : 'Inactive'}
+
+
+
+
+
+
+
+
+
+
+
+ Edit
+
+
+
+ {
+ setPartnershipToDelete(partnership.id);
+ setDeleteDialogOpen(true);
+ }}
+ >
+
+ Delete
+
+
+
+
+ )}
+
+
+
+
+ {/* Delete Dialog */}
+
+
+
+ Are you sure?
+
+ {partnershipToDelete
+ ? 'This will permanently delete this partnership.'
+ : `This will permanently delete ${selectedPartnerships.size} partnership(s).`}
+
+
+
+ setPartnershipToDelete(null)}>Cancel
+ {
+ if (partnershipToDelete) {
+ handleDelete(partnershipToDelete);
+ setPartnershipToDelete(null);
+ } else {
+ handleBulkDelete();
+ }
+ setDeleteDialogOpen(false);
+ }}
+ >
+ Delete
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/app/admin/projects/[id]/edit/page.tsx b/frontend/app/admin/projects/[id]/edit/page.tsx
new file mode 100644
index 0000000..ee56c36
--- /dev/null
+++ b/frontend/app/admin/projects/[id]/edit/page.tsx
@@ -0,0 +1,569 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+import { ArrowLeft, Save, Loader2, RotateCcw } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import ImageUpload from '@/components/ui/image-upload';
+import { FormField } from '@/components/ui/form-field';
+import { useToast } from '@/hooks/use-toast';
+import { useAutoSave } from '@/hooks/use-autosave';
+import { validateProject, generateSlug, ValidationRules } from '@/lib/validation';
+import type { Project, ProjectInput } from '@/lib/types';
+
+interface FormErrors {
+ title?: string;
+ description?: string;
+ slug?: string;
+ imageUrl?: string;
+ imageBase64?: string;
+ status?: string;
+ tags?: string;
+ links?: string;
+}
+
+interface EditProjectPageProps {
+ params: {
+ id: string;
+ };
+}
+
+export default function EditProjectPage({ params }: EditProjectPageProps) {
+ const router = useRouter();
+ const { toast } = useToast();
+ const [isLoading, setIsLoading] = useState(false);
+ const [isLoadingProject, setIsLoadingProject] = useState(true);
+ const [errors, setErrors] = useState({});
+ const [isSubmitted, setIsSubmitted] = useState(false);
+ const [slugError, setSlugError] = useState(null);
+ const [project, setProject] = useState(null);
+ const [formData, setFormData] = useState({
+ title: '',
+ slug: '',
+ description: '',
+ imageUrl: '',
+ imageBase64: '',
+ tags: '',
+ status: 'planned',
+ links: '',
+
+ });
+
+ // Track if user has made changes
+ const [hasUserMadeChanges, setHasUserMadeChanges] = useState(false);
+ const [originalFormData, setOriginalFormData] = useState(null);
+
+ // Auto-save functionality for edit form - only enabled after user makes changes
+ const { restoreSavedData, clearSavedData, hasSavedData } = useAutoSave({
+ key: `edit-project-${params.id}`,
+ data: formData,
+ enabled: !isLoading && !isLoadingProject && hasUserMadeChanges && project !== null,
+ onRestore: (data) => setFormData(data),
+ });
+
+ // Real-time form validation
+ const validateFormInRealTime = () => {
+ if (!isSubmitted) return;
+ const validation = validateProject(formData);
+ setErrors(validation.errors);
+ };
+
+ useEffect(() => {
+ validateFormInRealTime();
+ }, [formData, isSubmitted]);
+
+ // Detect changes from original data
+ useEffect(() => {
+ if (originalFormData && !hasUserMadeChanges) {
+ const currentDataString = JSON.stringify(formData);
+ const originalDataString = JSON.stringify(originalFormData);
+
+ if (currentDataString !== originalDataString) {
+ setHasUserMadeChanges(true);
+ }
+ }
+ }, [formData, originalFormData, hasUserMadeChanges]);
+
+ // Load existing project data
+ useEffect(() => {
+ const loadProject = async () => {
+ try {
+ setIsLoadingProject(true);
+ const response = await fetch(`/api/admin/projects/${params.id}`);
+
+ if (!response.ok) {
+ throw new Error('Failed to load project');
+ }
+
+ const data = await response.json();
+ const projectData = data.project;
+
+ setProject(projectData);
+
+ const originalData = {
+ title: projectData.title,
+ slug: projectData.slug,
+ description: projectData.description || '',
+ imageUrl: projectData.imageUrl || '',
+ imageBase64: projectData.imageBase64 || '',
+ tags: projectData.tags || '',
+ status: projectData.status,
+ links: projectData.links || '',
+
+ };
+
+ // Store original data for comparison
+ setOriginalFormData(originalData);
+
+ // Always load original data first, let user choose to restore draft
+ setFormData(originalData);
+
+ // Show draft restore option if available
+ setTimeout(() => {
+ if (hasSavedData()) {
+ toast({
+ title: 'Unsaved changes found',
+ description: 'You have a draft with unsaved changes.',
+ action: (
+ {
+ const draftData = restoreSavedData();
+ if (draftData) {
+ setFormData(draftData);
+ setHasUserMadeChanges(true);
+ toast({
+ title: 'Draft restored',
+ description: 'Your previous changes have been restored.',
+ duration: 2000,
+ });
+ }
+ }}
+ >
+ Restore
+
+ ),
+ duration: 10000,
+ });
+ }
+ }, 500);
+ } catch (error) {
+ console.error('Error loading project:', error);
+ setErrors({ title: 'Failed to load project' });
+ } finally {
+ setIsLoadingProject(false);
+ }
+ };
+
+ loadProject();
+ }, [params.id]);
+
+ // Auto-generate slug from title (but only if it hasn't been manually edited)
+ const generateSlug = (title: string) => {
+ return title
+ .toLowerCase()
+ .replace(/[^\w\s-]/g, '')
+ .replace(/\s+/g, '-')
+ .replace(/-+/g, '-')
+ .trim();
+ };
+
+ const handleTitleChange = (value: string) => {
+ const newSlug = generateSlug(value);
+ setFormData(prev => ({
+ ...prev,
+ title: value,
+ // Auto-update slug if it hasn't been manually edited
+ slug: project && prev.slug === generateSlug(prev.title) || !prev.slug ? newSlug : prev.slug
+ }));
+
+ // Clear title error if it exists
+ if (errors.title) {
+ setErrors(prev => ({ ...prev, title: undefined }));
+ }
+ };
+
+ const handleSlugChange = (value: string) => {
+ setFormData(prev => ({ ...prev, slug: value }));
+ setSlugError(null);
+
+ // Clear slug error if it exists
+ if (errors.slug) {
+ setErrors(prev => ({ ...prev, slug: undefined }));
+ }
+ };
+
+ // Async slug validation
+ const validateSlugUnique = async (slug: string): Promise => {
+ if (!slug) return 'URL slug is required';
+
+ const slugValidation = ValidationRules.slug(slug, 'URL slug');
+ if (slugValidation) return slugValidation;
+
+ // Skip uniqueness check if slug hasn't changed
+ if (project && slug === project.slug) return null;
+
+ try {
+ const response = await fetch(`/api/admin/projects?slug=${encodeURIComponent(slug)}`);
+ if (response.ok) {
+ const data = await response.json();
+ if (data.projects && data.projects.some((p: any) => p.slug === slug && p.id !== params.id)) {
+ return 'This URL slug is already taken';
+ }
+ }
+ } catch (error) {
+ return 'Could not validate URL slug';
+ }
+
+ return null;
+ };
+
+ const validateFormData = (): boolean => {
+ setIsSubmitted(true);
+ const validation = validateProject(formData);
+ setErrors(validation.errors);
+ return validation.isValid;
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!validateFormData()) {
+ toast({
+ title: 'Validation Error',
+ description: 'Please fix the errors above before submitting.',
+ variant: 'destructive',
+ });
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ const response = await fetch(`/api/admin/projects/${params.id}`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ ...formData,
+ links: formData.links ? formData.links : '',
+ imageBase64: formData.imageBase64 || '',
+
+ }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+
+ // Handle detailed validation errors from server
+ if (errorData.details) {
+ setErrors(errorData.details);
+ toast({
+ title: 'Validation Error',
+ description: errorData.error || 'Please fix the errors and try again.',
+ variant: 'destructive',
+ });
+ return;
+ }
+
+ throw new Error(errorData.error || 'Failed to update project');
+ }
+
+ // Clear auto-saved data and reset change tracking on successful submission
+ clearSavedData();
+ setHasUserMadeChanges(false);
+
+ toast({
+ title: 'Project Updated',
+ description: 'Your project has been updated successfully.',
+ });
+
+ router.push('/admin/projects');
+ router.refresh();
+ } catch (error) {
+ console.error('Error updating project:', error);
+ toast({
+ title: 'Error',
+ description: error instanceof Error ? error.message : 'Failed to update project',
+ variant: 'destructive',
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ if (isLoadingProject) {
+ return (
+
+
+
+
+
+ Back to Projects
+
+
+
+
Edit Project
+
Loading project data...
+
+
+
+
+
+
+
+
+ );
+ }
+
+ if (!project) {
+ return (
+
+
+
+
+
+ Back to Projects
+
+
+
+
Edit Project
+
Project not found
+
+
+
+
+ Project not found or failed to load
+
+ Back to Projects
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Page Header */}
+
+
+
+
+
+ Back to Projects
+
+
+
+
Edit Project
+
+ Editing: {project.title}
+
+
+
+
+ {hasSavedData() && (
+
{
+ const draftData = restoreSavedData();
+ if (draftData) {
+ setFormData(draftData);
+ setHasUserMadeChanges(true);
+ toast({
+ title: 'Draft restored',
+ description: 'Your previous changes have been restored.',
+ duration: 2000,
+ });
+ }
+ }}
+ className="flex items-center gap-2"
+ >
+
+ Restore Draft
+
+ )}
+
+
+ {/* Form */}
+
+
+
+ Project Details
+
+ Update the information for this project
+
+
+
+ {/* Title */}
+
+
+ {/* Slug */}
+
+
+ {/* Description */}
+ setFormData(prev => ({ ...prev, description: value }))}
+ placeholder="Describe your project in detail... (optional)"
+ required={false}
+ error={errors.description}
+ rows={4}
+ hint="Optional: Provide a description of your project's goals and outcomes"
+ />
+
+ {/* Status */}
+
+ Status
+ setFormData(prev => ({ ...prev, status: value as any }))}>
+
+
+
+
+ Planned
+ Active
+ Completed
+
+
+
+
+ {/* Project Image Upload Section */}
+
+
Project Image
+
+ {/* Image Upload */}
+
+
Upload Image
+
setFormData(prev => ({ ...prev, imageBase64: base64 || '' }))}
+ cropSize={400}
+ maxSize={10}
+ />
+
+ Recommended: Upload for best quality (400×400px)
+
+
+
+ {/* URL Input */}
+
+
Or use Image URL
+
setFormData(prev => ({ ...prev, imageUrl: e.target.value }))}
+ placeholder="https://example.com/image.jpg"
+ className={errors.imageUrl ? "border-red-500" : ""}
+ />
+ {errors.imageUrl && (
+
{errors.imageUrl}
+ )}
+
+ Alternative: External image URL
+
+
+
+
+ 🖼️ Uploaded images take priority over URLs and are stored securely in the database.
+
+
+
+ {/* Tags */}
+
+
Tags
+
setFormData(prev => ({ ...prev, tags: e.target.value }))}
+ placeholder="science, research, AI"
+ />
+
+ Comma-separated tags for categorizing your project
+
+
+
+ {/* Links */}
+
+
Links (JSON)
+
setFormData(prev => ({ ...prev, links: e.target.value }))}
+ placeholder='{"github": "https://github.com/...", "website": "https://example.com"}'
+ rows={3}
+ className={errors.links ? "border-red-500" : ""}
+ />
+
+ JSON object of links: {`{"type": "url", "github": "...", "website": "..."}`}
+
+ {errors.links && (
+ {errors.links}
+ )}
+
+
+ {/* Form Actions */}
+
+
+ {isLoading ? (
+ <>
+
+ Updating...
+ >
+ ) : (
+ <>
+
+ Update Project
+ >
+ )}
+
+
+ Cancel
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/app/admin/projects/[id]/members/page.tsx b/frontend/app/admin/projects/[id]/members/page.tsx
new file mode 100644
index 0000000..1b6eaa7
--- /dev/null
+++ b/frontend/app/admin/projects/[id]/members/page.tsx
@@ -0,0 +1,349 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useParams, useRouter } from 'next/navigation';
+import Link from 'next/link';
+import { ArrowLeft, Plus, X, Users } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { useToast } from "@/hooks/use-toast";
+import { Avatar } from '@/components/ui/avatar';
+import { Project, BoardMember } from '@/lib/types';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog';
+
+interface ProjectMemberAssignment {
+ id: string;
+ projectId: string;
+ memberId: string;
+ role: 'member' | 'coordinator';
+ joinedAt: string;
+ member: {
+ id: string;
+ name: string;
+ role: string;
+ roleType: string;
+ photoUrl: string;
+ photoBase64: string;
+ bio: string;
+ socials: string;
+ displayOrder: number;
+ active: boolean;
+ };
+}
+
+export default function ProjectMembersPage() {
+ const params = useParams();
+ const router = useRouter();
+ const { toast } = useToast();
+ const projectId = params.id as string;
+
+ const [project, setProject] = useState(null);
+ const [projectMembers, setProjectMembers] = useState([]);
+ const [availableMembers, setAvailableMembers] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
+ const [selectedMemberId, setSelectedMemberId] = useState('');
+ const [memberRole, setMemberRole] = useState<'member' | 'coordinator'>('member');
+ const [isAdding, setIsAdding] = useState(false);
+
+ // Load project and members data
+ useEffect(() => {
+ loadData();
+ }, [projectId]);
+
+ const loadData = async () => {
+ setLoading(true);
+ try {
+ // Load project details
+ const projectResponse = await fetch(`/api/admin/projects/${projectId}`);
+ if (projectResponse.ok) {
+ const projectData = await projectResponse.json();
+ setProject(projectData.project);
+ }
+
+ // Load project members
+ const membersResponse = await fetch(`/api/admin/projects/${projectId}/members`);
+ if (membersResponse.ok) {
+ const membersData = await membersResponse.json();
+ setProjectMembers(membersData.members || []);
+ }
+
+ // Load all available members
+ const allMembersResponse = await fetch('/api/admin/board');
+ if (allMembersResponse.ok) {
+ const allMembersData = await allMembersResponse.json();
+ setAvailableMembers(allMembersData.members || []);
+ }
+ } catch (error) {
+ console.error('Error loading data:', error);
+ toast({
+ title: "Error",
+ description: "Failed to load project data",
+ variant: "destructive",
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleAddMember = async () => {
+ if (!selectedMemberId) {
+ toast({
+ title: "Error",
+ description: "Please select a member",
+ variant: "destructive",
+ });
+ return;
+ }
+
+ setIsAdding(true);
+ try {
+ const response = await fetch(`/api/admin/projects/${projectId}/members`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ memberId: selectedMemberId,
+ role: memberRole,
+ }),
+ });
+
+ if (response.ok) {
+ toast({
+ title: "Success",
+ description: "Member added to project successfully",
+ });
+ setIsAddDialogOpen(false);
+ setSelectedMemberId('');
+ setMemberRole('member');
+ loadData(); // Reload data
+ } else {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'Failed to add member');
+ }
+ } catch (error: any) {
+ toast({
+ title: "Error",
+ description: error.message || "Failed to add member to project",
+ variant: "destructive",
+ });
+ } finally {
+ setIsAdding(false);
+ }
+ };
+
+ const handleRemoveMember = async (memberId: string) => {
+ try {
+ const response = await fetch(`/api/admin/projects/${projectId}/members?memberId=${memberId}`, {
+ method: 'DELETE',
+ });
+
+ if (response.ok) {
+ toast({
+ title: "Success",
+ description: "Member removed from project successfully",
+ });
+ loadData(); // Reload data
+ } else {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'Failed to remove member');
+ }
+ } catch (error: any) {
+ toast({
+ title: "Error",
+ description: error.message || "Failed to remove member from project",
+ variant: "destructive",
+ });
+ }
+ };
+
+ // Filter available members to exclude those already assigned
+ const assignedMemberIds = new Set(projectMembers.map(pm => pm.memberId));
+ const unassignedMembers = availableMembers.filter(member =>
+ !assignedMemberIds.has(member.id) && member.active
+ );
+
+ if (loading) {
+ return (
+
+
+
+
+
Loading project members...
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Back button and header */}
+
+
+
+
+ Back to Projects
+
+
+
+
+
+
Project Team
+
+ {project ? `Manage team members for "${project.title}"` : 'Loading...'}
+
+
+
+
+
+
+
+ Add Member
+
+
+
+
+ Add Team Member
+
+ Select a member to add to this project team.
+
+
+
+
+
+
Select Member
+
+
+
+
+
+ {unassignedMembers.map((member) => (
+
+
+
+
+
{member.name}
+
{member.role}
+
+
+
+ ))}
+
+
+
+
+
+ Role in Project
+ setMemberRole(value)}>
+
+
+
+
+ Member
+ Coordinator
+
+
+
+
+
+
+ setIsAddDialogOpen(false)}
+ disabled={isAdding}
+ >
+ Cancel
+
+
+ {isAdding ? 'Adding...' : 'Add Member'}
+
+
+
+
+
+
+
+ {/* Project Members List */}
+
+
+
+
+ Team Members ({projectMembers.length})
+
+
+ Members currently assigned to this project
+
+
+
+ {projectMembers.length === 0 ? (
+
+
+
No team members assigned
+
+ Start building your project team by adding members.
+
+
setIsAddDialogOpen(true)}>
+
+ Add First Member
+
+
+ ) : (
+
+ {projectMembers.map((assignment) => (
+
+
+
+
+
{assignment.member.name}
+
+ {assignment.role}
+
+
+ {assignment.member.role} • Joined {new Date(assignment.joinedAt).toLocaleDateString()}
+
+
+
+
+
handleRemoveMember(assignment.memberId)}
+ className="text-red-600 hover:text-red-700"
+ >
+
+
+
+ ))}
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/app/admin/projects/new/page.tsx b/frontend/app/admin/projects/new/page.tsx
new file mode 100644
index 0000000..8ee34e6
--- /dev/null
+++ b/frontend/app/admin/projects/new/page.tsx
@@ -0,0 +1,417 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+import { ArrowLeft, Save, Loader2, RotateCcw, AlertCircle } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import ImageUpload from '@/components/ui/image-upload';
+import { FormField } from '@/components/ui/form-field';
+import { useToast } from '@/hooks/use-toast';
+import { useAutoSave } from '@/hooks/use-autosave';
+import { validateProject, generateSlug, ValidationRules } from '@/lib/validation';
+import type { ProjectInput } from '@/lib/types';
+
+interface FormErrors {
+ title?: string;
+ description?: string;
+ slug?: string;
+ imageUrl?: string;
+ imageBase64?: string;
+ status?: string;
+ tags?: string;
+ links?: string;
+}
+
+export default function NewProjectPage() {
+ const router = useRouter();
+ const { toast } = useToast();
+ const [isLoading, setIsLoading] = useState(false);
+ const [errors, setErrors] = useState({});
+ const [isSubmitted, setIsSubmitted] = useState(false);
+ const [slugError, setSlugError] = useState(null);
+ const [formData, setFormData] = useState({
+ title: '',
+ slug: '',
+ description: '',
+ imageUrl: '',
+ imageBase64: '',
+ tags: '',
+ status: 'planned',
+ links: '',
+
+ });
+
+ // Track if user has made changes (for new forms, any non-empty content counts as changes)
+ const [hasUserMadeChanges, setHasUserMadeChanges] = useState(false);
+
+ // Auto-save functionality - only enabled after user makes changes
+ const { restoreSavedData, clearSavedData, hasSavedData } = useAutoSave({
+ key: 'new-project',
+ data: formData,
+ enabled: !isLoading && hasUserMadeChanges,
+ onRestore: (data) => setFormData(data),
+ });
+
+ // Check for saved data on component mount
+ useEffect(() => {
+ if (hasSavedData()) {
+ // Show restore option
+ toast({
+ title: 'Unsaved changes found',
+ description: 'Would you like to restore your previous draft?',
+ action: (
+ restoreSavedData()}
+ >
+ Restore
+
+ ),
+ duration: 10000,
+ });
+ }
+ }, []);
+
+ // Real-time form validation
+ const validateFormInRealTime = () => {
+ if (!isSubmitted) return;
+ const validation = validateProject(formData);
+ setErrors(validation.errors);
+ };
+
+ useEffect(() => {
+ validateFormInRealTime();
+ }, [formData, isSubmitted]);
+
+ // Detect changes for new form (any meaningful content)
+ useEffect(() => {
+ if (!hasUserMadeChanges) {
+ const hasContent = Object.values(formData).some(value =>
+ value !== null && value !== undefined && String(value).trim() !== '' &&
+ value !== 0 && value !== 'planned' // ignore default values
+ );
+
+ if (hasContent) {
+ setHasUserMadeChanges(true);
+ }
+ }
+ }, [formData, hasUserMadeChanges]);
+
+ const handleTitleChange = (value: string) => {
+ const newSlug = generateSlug(value);
+ setFormData(prev => ({
+ ...prev,
+ title: value,
+ // Auto-update slug if it hasn't been manually edited
+ slug: prev.slug === generateSlug(prev.title) || !prev.slug ? newSlug : prev.slug
+ }));
+
+ // Clear title error if it exists
+ if (errors.title) {
+ setErrors(prev => ({ ...prev, title: undefined }));
+ }
+ };
+
+ const handleSlugChange = (value: string) => {
+ setFormData(prev => ({ ...prev, slug: value }));
+ setSlugError(null);
+
+ // Clear slug error if it exists
+ if (errors.slug) {
+ setErrors(prev => ({ ...prev, slug: undefined }));
+ }
+ };
+
+ // Async slug validation
+ const validateSlugUnique = async (slug: string): Promise => {
+ if (!slug) return 'URL slug is required';
+
+ const slugValidation = ValidationRules.slug(slug, 'URL slug');
+ if (slugValidation) return slugValidation;
+
+ try {
+ const response = await fetch(`/api/admin/projects?slug=${encodeURIComponent(slug)}`);
+ if (response.ok) {
+ const data = await response.json();
+ if (data.projects && data.projects.some((p: any) => p.slug === slug)) {
+ return 'This URL slug is already taken';
+ }
+ }
+ } catch (error) {
+ return 'Could not validate URL slug';
+ }
+
+ return null;
+ };
+
+ const validateFormData = (): boolean => {
+ setIsSubmitted(true);
+ const validation = validateProject(formData);
+ setErrors(validation.errors);
+ return validation.isValid;
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!validateFormData()) {
+ toast({
+ title: 'Validation Error',
+ description: 'Please fix the errors above before submitting.',
+ variant: 'destructive',
+ });
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ const response = await fetch('/api/admin/projects', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ ...formData,
+ links: formData.links ? formData.links : '',
+ imageBase64: formData.imageBase64 || '',
+ displayOrder: 0, // Default display order, will be managed by drag-and-drop
+ }),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+
+ // Handle detailed validation errors from server
+ if (errorData.details) {
+ setErrors(errorData.details);
+ toast({
+ title: 'Validation Error',
+ description: errorData.error || 'Please fix the errors and try again.',
+ variant: 'destructive',
+ });
+ return;
+ }
+
+ throw new Error(errorData.error || 'Failed to create project');
+ }
+
+ // Clear auto-saved data on successful submission
+ clearSavedData();
+
+ toast({
+ title: 'Project Created',
+ description: 'Your project has been created successfully.',
+ });
+
+ router.push('/admin/projects');
+ router.refresh();
+ } catch (error) {
+ console.error('Error creating project:', error);
+ toast({
+ title: 'Error',
+ description: error instanceof Error ? error.message : 'Failed to create project',
+ variant: 'destructive',
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+ {/* Page Header */}
+
+
+
+
+
+ Back to Projects
+
+
+
+
Create New Project
+
+ Add a new project to showcase your club's initiatives
+
+
+
+
+ {hasSavedData() && (
+
restoreSavedData()}
+ className="flex items-center gap-2"
+ >
+
+ Restore Draft
+
+ )}
+
+
+ {/* Form */}
+
+
+
+ Project Details
+
+ Fill in the information about your new project
+
+
+
+ {/* Title */}
+
+
+ {/* Slug */}
+
+
+ {/* Description */}
+ setFormData(prev => ({ ...prev, description: value }))}
+ placeholder="Describe your project in detail... (optional)"
+ required={false}
+ error={errors.description}
+ rows={4}
+ hint="Optional: Provide a description of your project's goals and outcomes"
+ />
+
+ {/* Status */}
+
+ Status
+ setFormData(prev => ({ ...prev, status: value as any }))}>
+
+
+
+
+ Planned
+ Active
+ Completed
+
+
+
+
+ {/* Project Image Upload Section */}
+
+
Project Image
+
+ {/* Image Upload */}
+
+
Upload Image
+
setFormData(prev => ({ ...prev, imageBase64: base64 || '' }))}
+ cropSize={720}
+ maxSize={10}
+ />
+
+ Recommended: Upload for best quality (720×720px)
+
+
+
+ {/* URL Input */}
+
+
Or use Image URL
+
setFormData(prev => ({ ...prev, imageUrl: e.target.value }))}
+ placeholder="https://example.com/image.jpg"
+ className={errors.imageUrl ? "border-red-500" : ""}
+ />
+ {errors.imageUrl && (
+
{errors.imageUrl}
+ )}
+
+ Alternative: External image URL
+
+
+
+
+ 🖼️ Uploaded images take priority over URLs and are stored securely in the database.
+
+
+
+ {/* Tags */}
+ setFormData(prev => ({ ...prev, tags: value }))}
+ placeholder="science, research, AI, data visualization"
+ error={errors.tags}
+ maxLength={200}
+ hint="Comma-separated tags for categorizing and filtering your project"
+ />
+
+ {/* Links */}
+ setFormData(prev => ({ ...prev, links: value }))}
+ placeholder='{"github": "https://github.com/...", "website": "https://example.com", "demo": "https://demo.com"}'
+ rows={3}
+ error={errors.links}
+ hint={`JSON object of project links. Example: {"github": "...", "website": "...", "demo": "..."}`}
+ />
+
+ {/* Form Actions */}
+
+
+ {isLoading ? (
+ <>
+
+ Creating...
+ >
+ ) : (
+ <>
+
+ Create Project
+ >
+ )}
+
+
+ Cancel
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/app/admin/projects/page.tsx b/frontend/app/admin/projects/page.tsx
new file mode 100644
index 0000000..13ee4a8
--- /dev/null
+++ b/frontend/app/admin/projects/page.tsx
@@ -0,0 +1,648 @@
+'use client';
+
+import { useState, useEffect, Suspense } from 'react';
+import Link from 'next/link';
+import { Plus, Eye, Search, Filter, Trash2, Edit, ExternalLink, MoreVertical, Users } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Badge } from '@/components/ui/badge';
+import { useToast } from "@/hooks/use-toast";
+import { Project } from '@/lib/types';
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@/components/ui/alert-dialog';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { SortableList } from '@/components/ui/sortable-list';
+
+interface ProjectsTableProps {
+ projects: Project[];
+ onProjectsChange: () => void;
+}
+
+function ProjectStatusBadge({ status }: { status: string }) {
+ const variants = {
+ 'planned': 'bg-orange-100 text-orange-800',
+ 'active': 'bg-green-100 text-green-800',
+ 'completed': 'bg-blue-100 text-blue-800'
+ };
+
+ return (
+
+ {status}
+
+ );
+}
+
+function ProjectsTable({ projects, onProjectsChange }: ProjectsTableProps) {
+ const { toast } = useToast();
+ const [selectedProjects, setSelectedProjects] = useState>(new Set());
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [projectToDelete, setProjectToDelete] = useState(null);
+ const [isBulkDeleting, setIsBulkDeleting] = useState(false);
+
+ const handleSelectProject = (projectId: string, checked: boolean) => {
+ const newSelected = new Set(selectedProjects);
+ if (checked) {
+ newSelected.add(projectId);
+ } else {
+ newSelected.delete(projectId);
+ }
+ setSelectedProjects(newSelected);
+ };
+
+ const handleSelectAll = (checked: boolean) => {
+ if (checked) {
+ setSelectedProjects(new Set(projects.map(p => p.id)));
+ } else {
+ setSelectedProjects(new Set());
+ }
+ };
+
+ const handleReorder = async (reorderedProjects: Project[]) => {
+ try {
+ // Create updates array with new display orders
+ const updates = reorderedProjects.map((project, index) => ({
+ id: project.id,
+ displayOrder: index + 1
+ }));
+
+ const response = await fetch('/api/admin/projects/bulk', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ action: 'updateDisplayOrder',
+ projectIds: updates.map(u => u.id),
+ data: { updates }
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to update project order');
+ }
+
+ toast({
+ title: "Success",
+ description: "Project order updated successfully.",
+ });
+
+ // Only refresh data from server after successful save
+ onProjectsChange();
+ } catch (error) {
+ console.error('Error updating project order:', error);
+ toast({
+ title: "Error",
+ description: "Failed to update project order. Please try again.",
+ variant: "destructive",
+ });
+ throw error; // Re-throw to let SortableList handle the revert
+ }
+ };
+
+ const handleDeleteProject = async (projectId: string) => {
+ try {
+ setIsDeleting(true);
+ const response = await fetch(`/api/admin/projects/${projectId}`, {
+ method: 'DELETE',
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to delete project');
+ }
+
+ toast({
+ title: "Success",
+ description: "Project deleted successfully.",
+ });
+
+ onProjectsChange();
+ } catch (error) {
+ console.error('Error deleting project:', error);
+ toast({
+ title: "Error",
+ description: "Failed to delete project. Please try again.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsDeleting(false);
+ setProjectToDelete(null);
+ }
+ };
+
+ const handleBulkDelete = async () => {
+ try {
+ setIsBulkDeleting(true);
+ const response = await fetch('/api/admin/projects/bulk', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ action: 'delete',
+ projectIds: Array.from(selectedProjects),
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to delete projects');
+ }
+
+ const result = await response.json();
+
+ if (result.errors && result.errors.length > 0) {
+ toast({
+ title: "Partial Success",
+ description: `${result.success.length} projects deleted, but ${result.errors.length} failed.`,
+ variant: "destructive",
+ });
+ } else {
+ toast({
+ title: "Success",
+ description: `${selectedProjects.size} projects deleted successfully.`,
+ });
+ }
+
+ setSelectedProjects(new Set());
+ onProjectsChange();
+ } catch (error) {
+ console.error('Error deleting projects:', error);
+ toast({
+ title: "Error",
+ description: "Failed to delete projects. Please try again.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsBulkDeleting(false);
+ }
+ };
+
+ const handleBulkStatusUpdate = async (newStatus: string) => {
+ try {
+ const response = await fetch('/api/admin/projects/bulk', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ action: 'updateStatus',
+ projectIds: Array.from(selectedProjects),
+ data: { status: newStatus }
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to update project status');
+ }
+
+ const result = await response.json();
+
+ if (result.errors && result.errors.length > 0) {
+ toast({
+ title: "Partial Success",
+ description: `${result.success.length} projects updated, but ${result.errors.length} failed.`,
+ variant: "destructive",
+ });
+ } else {
+ toast({
+ title: "Success",
+ description: `${selectedProjects.size} projects updated to ${newStatus}.`,
+ });
+ }
+
+ setSelectedProjects(new Set());
+ onProjectsChange();
+ } catch (error) {
+ console.error('Error updating project status:', error);
+ toast({
+ title: "Error",
+ description: "Failed to update project status. Please try again.",
+ variant: "destructive",
+ });
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+ Projects
+
+ Manage your projects. Drag and drop to reorder.
+
+
+
+ {selectedProjects.size > 0 && (
+ <>
+
+
+
+ Update Status ({selectedProjects.size})
+
+
+
+ handleBulkStatusUpdate('planned')}>
+ Set to Planned
+
+ handleBulkStatusUpdate('active')}>
+ Set to Active
+
+ handleBulkStatusUpdate('completed')}>
+ Set to Completed
+
+
+
+
+
+
+
+
+ Delete ({selectedProjects.size})
+
+
+
+
+ Delete Projects
+
+ Are you sure you want to delete {selectedProjects.size} selected projects?
+ This action cannot be undone.
+
+
+
+ Cancel
+
+ Delete Projects
+
+
+
+
+ >
+ )}
+
+
+
+
+ {projects.length === 0 ? (
+
+
No projects found.
+
+
+
+ Create your first project
+
+
+
+ ) : (
+
+ {/* Select All */}
+
+ 0}
+ onCheckedChange={handleSelectAll}
+ />
+
+ Select All ({projects.length} projects)
+
+
+
+ {/* Sortable Projects List */}
+
project.id}
+ className="space-y-2"
+ >
+ {(project) => (
+
+
+ handleSelectProject(project.id, checked as boolean)
+ }
+ />
+
+
+
+
+
{project.title}
+
+ {project.description || 'No description'}
+
+
+
+ {project.tags && (
+
+ {project.tags}
+
+ )}
+
+
+
+
+ {new Date(project.updatedAt).toLocaleDateString()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Edit
+
+
+
+
+
+ Manage Team
+
+
+
+
+
+ View
+
+
+
+ setProjectToDelete(project.id)}
+ className="text-red-600"
+ >
+
+ Delete
+
+
+
+
+
+ )}
+
+
+ )}
+
+
+
+ {/* Delete Confirmation Dialog */}
+ setProjectToDelete(null)}>
+
+
+ Delete Project
+
+ Are you sure you want to delete this project? This action cannot be undone.
+
+
+
+ Cancel
+ projectToDelete && handleDeleteProject(projectToDelete)}
+ disabled={isDeleting}
+ className="bg-red-600 hover:bg-red-700"
+ >
+ {isDeleting ? 'Deleting...' : 'Delete Project'}
+
+
+
+
+ >
+ );
+}
+
+function ProjectsManager() {
+ const [projects, setProjects] = useState([]);
+ const [filteredProjects, setFilteredProjects] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [searchTerm, setSearchTerm] = useState('');
+ const [statusFilter, setStatusFilter] = useState('all');
+ const { toast } = useToast();
+
+ const loadProjects = async () => {
+ try {
+ setIsLoading(true);
+ const response = await fetch('/api/admin/projects');
+
+ if (!response.ok) {
+ throw new Error('Failed to load projects');
+ }
+
+ const data = await response.json();
+ const sortedProjects = data.projects.sort((a: Project, b: Project) => {
+ if (a.displayOrder !== b.displayOrder) {
+ return a.displayOrder - b.displayOrder;
+ }
+ return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
+ });
+
+ setProjects(sortedProjects);
+ } catch (error) {
+ console.error('Error loading projects:', error);
+ toast({
+ title: "Error",
+ description: "Failed to load projects. Please refresh the page.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadProjects();
+ }, []);
+
+ // Apply filters whenever projects, searchTerm, or statusFilter changes
+ useEffect(() => {
+ let filtered = projects;
+
+ // Apply search filter
+ if (searchTerm) {
+ const search = searchTerm.toLowerCase();
+ filtered = filtered.filter(project =>
+ project.title.toLowerCase().includes(search) ||
+ (project.description && project.description.toLowerCase().includes(search)) ||
+ (project.tags && project.tags.toLowerCase().includes(search))
+ );
+ }
+
+ // Apply status filter
+ if (statusFilter !== 'all') {
+ filtered = filtered.filter(project => project.status === statusFilter);
+ }
+
+ setFilteredProjects(filtered);
+ }, [projects, searchTerm, statusFilter]);
+
+ if (isLoading) {
+ return ;
+ }
+
+ return (
+
+ {/* Page Header */}
+
+
+
Projects
+
+ Manage your club's projects and initiatives.
+
+
+
+
+
+
+ View Projects Page
+
+
+
+
+
+ New Project
+
+
+
+
+
+ {/* Search and Filters */}
+
+
+ Search & Filter
+
+ Find specific projects or filter by status.
+
+
+
+
+
+ setSearchTerm(e.target.value)}
+ className="max-w-sm"
+ />
+
+
+
+
+
+
+ All Status
+ Planned
+ Active
+ Completed
+
+
+ {(searchTerm || statusFilter !== 'all') && (
+
{
+ setSearchTerm('');
+ setStatusFilter('all');
+ }}
+ >
+ Clear Filters
+
+ )}
+
+ {filteredProjects.length !== projects.length && (
+
+ Showing {filteredProjects.length} of {projects.length} projects
+
+ )}
+
+
+
+ {/* Projects Table */}
+
+
+ );
+}
+
+function ProjectsLoadingSkeleton() {
+ return (
+
+
+
+
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+
+
+ );
+}
+
+export default function ProjectsPage() {
+ return (
+ }>
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/app/admin/settings/page.tsx b/frontend/app/admin/settings/page.tsx
new file mode 100644
index 0000000..bada2d0
--- /dev/null
+++ b/frontend/app/admin/settings/page.tsx
@@ -0,0 +1,133 @@
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+
+export default function AdminSettingsPage() {
+ return (
+
+ {/* Page Header */}
+
+
Settings
+
+ Manage your website configuration and preferences
+
+
+
+ {/* Settings Cards */}
+
+ {/* Database Settings */}
+
+
+ Database
+ MongoDB connection and data management
+
+
+
+ Status
+ Connected
+
+
+ Type
+ MongoDB Atlas
+
+
+ Test Connection
+
+
+
+
+ {/* Authentication */}
+
+
+ Authentication
+ Admin access and security settings
+
+
+
+ Method
+ HTTP Basic Auth
+
+
+ Admin User
+ Configured
+
+
+ Change Password
+
+
+
+
+ {/* Site Information */}
+
+
+ Site Information
+ Basic website configuration
+
+
+
+ Site Name
+ Gradient Science Club
+
+
+ Environment
+ Development
+
+
+ Edit Site Info
+
+
+
+
+ {/* Backup & Export */}
+
+
+ Data Management
+ Backup, export, and import data
+
+
+
+ Last Backup
+ Never
+
+
+
+ Export Data
+
+
+ Import Data
+
+
+
+
+
+
+ {/* System Information */}
+
+
+ System Information
+ Technical details about your installation
+
+
+
+
+
Framework
+
Next.js 14
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/app/api/admin/auth/route.ts b/frontend/app/api/admin/auth/route.ts
new file mode 100644
index 0000000..d6f074a
--- /dev/null
+++ b/frontend/app/api/admin/auth/route.ts
@@ -0,0 +1,46 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { adminAuthRepo } from '@/lib/repositories/adminAuth';
+
+export async function POST(request: NextRequest) {
+ try {
+ const { username, password } = await request.json();
+
+ if (!username || !password) {
+ return NextResponse.json(
+ { error: 'Username and password are required' },
+ { status: 400 }
+ );
+ }
+
+ const isValid = await adminAuthRepo.authenticate(username, password);
+
+ if (isValid) {
+ return NextResponse.json({ authenticated: true });
+ } else {
+ return NextResponse.json(
+ { error: 'Invalid credentials' },
+ { status: 401 }
+ );
+ }
+ } catch (error) {
+ console.error('Authentication API error:', error);
+ return NextResponse.json(
+ { error: 'Internal server error' },
+ { status: 500 }
+ );
+ }
+}
+
+// GET endpoint to check if admin users exist
+export async function GET() {
+ try {
+ const hasAdmins = await adminAuthRepo.hasAdminUsers();
+ return NextResponse.json({ hasAdminUsers: hasAdmins });
+ } catch (error) {
+ console.error('Admin check error:', error);
+ return NextResponse.json(
+ { error: 'Internal server error' },
+ { status: 500 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/frontend/app/api/admin/board/[id]/route.ts b/frontend/app/api/admin/board/[id]/route.ts
new file mode 100644
index 0000000..1c440ee
--- /dev/null
+++ b/frontend/app/api/admin/board/[id]/route.ts
@@ -0,0 +1,113 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAdminAuth } from '@/lib/auth';
+import { boardMembersRepo } from '@/lib/repositories';
+import { revalidatePath } from 'next/cache';
+
+// GET /api/admin/board/[id] - Get board member by ID
+export async function GET(
+ request: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ // Validate authentication
+ const auth = await requireAdminAuth(request);
+ if (!auth.isAuthenticated) {
+ return NextResponse.json({ error: auth.error }, { status: 401 });
+ }
+
+ try {
+ const member = await boardMembersRepo.findById(params.id);
+
+ if (!member) {
+ return NextResponse.json({ error: 'Board member not found' }, { status: 404 });
+ }
+
+ return NextResponse.json({ member });
+ } catch (error) {
+ console.error('Error fetching board member:', error);
+ return NextResponse.json({ error: 'Failed to fetch board member' }, { status: 500 });
+ }
+}
+
+// PUT /api/admin/board/[id] - Update board member
+export async function PUT(
+ request: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ // Validate authentication
+ const auth = await requireAdminAuth(request);
+ if (!auth.isAuthenticated) {
+ return NextResponse.json({ error: auth.error }, { status: 401 });
+ }
+
+ try {
+ const memberData = await request.json();
+
+ // Clean up empty role strings for members
+ if (memberData.roleType === 'member' && (memberData.role === '' || memberData.role === null)) {
+ memberData.role = undefined;
+ }
+
+ // Import validation function
+ const { validateBoardMember } = await import('@/lib/validation');
+
+ // Validate the board member data
+ const validation = validateBoardMember(memberData);
+ if (!validation.isValid) {
+ return NextResponse.json(
+ {
+ error: 'Validation failed',
+ details: validation.errors,
+ fieldErrors: validation.fieldErrors
+ },
+ { status: 400 }
+ );
+ }
+
+ const member = await boardMembersRepo.update(params.id, memberData);
+
+ // Revalidate pages that display board members
+ revalidatePath('/board');
+ revalidatePath('/');
+ revalidatePath('/api/board');
+
+ return NextResponse.json({ member });
+ } catch (error: any) {
+ console.error('Error updating board member:', error);
+ if (error.message?.includes('not found')) {
+ return NextResponse.json({ error: 'Board member not found' }, { status: 404 });
+ }
+ if (error.message?.includes('already exists')) {
+ return NextResponse.json({ error: error.message }, { status: 409 });
+ }
+ return NextResponse.json({ error: 'Failed to update board member' }, { status: 500 });
+ }
+}
+
+// DELETE /api/admin/board/[id] - Delete board member
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ // Validate authentication
+ const auth = await requireAdminAuth(request);
+ if (!auth.isAuthenticated) {
+ return NextResponse.json({ error: auth.error }, { status: 401 });
+ }
+
+ try {
+ await boardMembersRepo.delete(params.id);
+
+ // Revalidate pages that display board members
+ revalidatePath('/board');
+ revalidatePath('/');
+ revalidatePath('/api/board');
+
+ return NextResponse.json({ message: 'Board member deleted successfully' });
+ } catch (error: any) {
+ console.error('Error deleting board member:', error);
+ if (error.message?.includes('not found')) {
+ return NextResponse.json({ error: 'Board member not found' }, { status: 404 });
+ }
+ return NextResponse.json({ error: 'Failed to delete board member' }, { status: 500 });
+ }
+}
\ No newline at end of file
diff --git a/frontend/app/api/admin/board/bulk/route.ts b/frontend/app/api/admin/board/bulk/route.ts
new file mode 100644
index 0000000..ea71e7b
--- /dev/null
+++ b/frontend/app/api/admin/board/bulk/route.ts
@@ -0,0 +1,133 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { boardMembersRepo } from '@/lib/repositories/boardMembers';
+import { requireAdminAuth } from '@/lib/auth';
+import { revalidatePath } from 'next/cache';
+
+export async function POST(request: NextRequest) {
+ // Validate authentication
+ const auth = await requireAdminAuth(request);
+ if (!auth.isAuthenticated) {
+ return NextResponse.json({ error: auth.error }, { status: 401 });
+ }
+
+ try {
+ const body = await request.json();
+ const { action, memberIds, data } = body;
+
+ if (!action || !Array.isArray(memberIds) || memberIds.length === 0) {
+ return NextResponse.json(
+ { error: 'Action and memberIds array are required' },
+ { status: 400 }
+ );
+ }
+
+ const results = {
+ success: [] as string[],
+ errors: [] as { id: string; error: string }[],
+ };
+
+ switch (action) {
+ case 'delete':
+ for (const memberId of memberIds) {
+ try {
+ await boardMembersRepo.delete(memberId);
+ results.success.push(memberId);
+ } catch (error) {
+ results.errors.push({
+ id: memberId,
+ error: error instanceof Error ? error.message : 'Failed to delete',
+ });
+ }
+ }
+ break;
+
+ case 'updateStatus':
+ if (typeof data?.active !== 'boolean') {
+ return NextResponse.json(
+ { error: 'Active status (boolean) is required for updateStatus action' },
+ { status: 400 }
+ );
+ }
+
+ for (const memberId of memberIds) {
+ try {
+ await boardMembersRepo.update(memberId, { active: data.active });
+ results.success.push(memberId);
+ } catch (error) {
+ results.errors.push({
+ id: memberId,
+ error: error instanceof Error ? error.message : 'Failed to update status',
+ });
+ }
+ }
+ break;
+
+ case 'updateDisplayOrder':
+ if (!data?.updates || !Array.isArray(data.updates)) {
+ return NextResponse.json(
+ { error: 'Updates array is required for updateDisplayOrder action' },
+ { status: 400 }
+ );
+ }
+
+ for (const update of data.updates) {
+ try {
+ await boardMembersRepo.update(update.id, { displayOrder: update.displayOrder });
+ results.success.push(update.id);
+ } catch (error) {
+ results.errors.push({
+ id: update.id,
+ error: error instanceof Error ? error.message : 'Failed to update display order',
+ });
+ }
+ }
+ break;
+
+ case 'updateRoleType':
+ if (!data?.roleType || !['board_member', 'coordinator', 'member'].includes(data.roleType)) {
+ return NextResponse.json(
+ { error: 'Valid roleType (board_member, coordinator, or member) is required for updateRoleType action' },
+ { status: 400 }
+ );
+ }
+
+ for (const memberId of memberIds) {
+ try {
+ await boardMembersRepo.update(memberId, { roleType: data.roleType });
+ results.success.push(memberId);
+ } catch (error) {
+ results.errors.push({
+ id: memberId,
+ error: error instanceof Error ? error.message : 'Failed to update role type',
+ });
+ }
+ }
+ break;
+
+ default:
+ return NextResponse.json(
+ { error: `Unknown action: ${action}` },
+ { status: 400 }
+ );
+ }
+
+ // Revalidate pages that display board members if any operations succeeded
+ if (results.success.length > 0) {
+ try {
+ revalidatePath('/board');
+ revalidatePath('/admin/board');
+ revalidatePath('/');
+ } catch (revalidateError) {
+ console.warn('Failed to revalidate paths:', revalidateError);
+ }
+ }
+
+ return NextResponse.json(results);
+ } catch (error) {
+ console.error('Bulk board member operation error:', error);
+ return NextResponse.json(
+ { error: 'Internal server error' },
+ { status: 500 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/frontend/app/api/admin/board/route.ts b/frontend/app/api/admin/board/route.ts
new file mode 100644
index 0000000..66ccc84
--- /dev/null
+++ b/frontend/app/api/admin/board/route.ts
@@ -0,0 +1,70 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAdminAuth } from '@/lib/auth';
+import { boardMembersRepo } from '@/lib/repositories';
+import { revalidatePath } from 'next/cache';
+
+// GET /api/admin/board - List all board members
+export async function GET(request: NextRequest) {
+ // Validate authentication
+ const auth = await requireAdminAuth(request);
+ if (!auth.isAuthenticated) {
+ return NextResponse.json({ error: auth.error }, { status: 401 });
+ }
+
+ try {
+ const members = await boardMembersRepo.findAll();
+ return NextResponse.json({ members });
+ } catch (error) {
+ console.error('Error fetching board members:', error);
+ return NextResponse.json({ error: 'Failed to fetch board members' }, { status: 500 });
+ }
+}
+
+// POST /api/admin/board - Create new board member
+export async function POST(request: NextRequest) {
+ // Validate authentication
+ const auth = await requireAdminAuth(request);
+ if (!auth.isAuthenticated) {
+ return NextResponse.json({ error: auth.error }, { status: 401 });
+ }
+
+ try {
+ const memberData = await request.json();
+
+ // Clean up empty role strings for members
+ if (memberData.roleType === 'member' && (memberData.role === '' || memberData.role === null)) {
+ memberData.role = undefined;
+ }
+
+ // Import validation function
+ const { validateBoardMember } = await import('@/lib/validation');
+
+ // Validate the board member data
+ const validation = validateBoardMember(memberData);
+ if (!validation.isValid) {
+ return NextResponse.json(
+ {
+ error: 'Validation failed',
+ details: validation.errors,
+ fieldErrors: validation.fieldErrors
+ },
+ { status: 400 }
+ );
+ }
+
+ const member = await boardMembersRepo.create(memberData);
+
+ // Revalidate pages that display board members
+ revalidatePath('/board');
+ revalidatePath('/');
+ revalidatePath('/api/board');
+
+ return NextResponse.json({ member }, { status: 201 });
+ } catch (error: any) {
+ console.error('Error creating board member:', error);
+ if (error.message?.includes('already exists')) {
+ return NextResponse.json({ error: error.message }, { status: 409 });
+ }
+ return NextResponse.json({ error: 'Failed to create board member' }, { status: 500 });
+ }
+}
\ No newline at end of file
diff --git a/frontend/app/api/admin/partnerships/[id]/route.ts b/frontend/app/api/admin/partnerships/[id]/route.ts
new file mode 100644
index 0000000..f9915a2
--- /dev/null
+++ b/frontend/app/api/admin/partnerships/[id]/route.ts
@@ -0,0 +1,99 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAdminAuth } from '@/lib/auth';
+import { partnershipsRepo } from '@/lib/repositories/partnerships';
+import { revalidatePath } from 'next/cache';
+
+// GET /api/admin/partnerships/[id] - Get partnership by ID
+export async function GET(
+ request: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ // Validate authentication
+ const auth = await requireAdminAuth(request);
+ if (!auth.isAuthenticated) {
+ return NextResponse.json({ error: auth.error }, { status: 401 });
+ }
+
+ try {
+ const partnership = await partnershipsRepo.findById(params.id);
+
+ if (!partnership) {
+ return NextResponse.json({ error: 'Partnership not found' }, { status: 404 });
+ }
+
+ return NextResponse.json({ partnership });
+ } catch (error) {
+ console.error('Error fetching partnership:', error);
+ return NextResponse.json({ error: 'Failed to fetch partnership' }, { status: 500 });
+ }
+}
+
+// PUT /api/admin/partnerships/[id] - Update partnership
+export async function PUT(
+ request: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ // Validate authentication
+ const auth = await requireAdminAuth(request);
+ if (!auth.isAuthenticated) {
+ return NextResponse.json({ error: auth.error }, { status: 401 });
+ }
+
+ try {
+ const partnershipData = await request.json();
+
+ // Basic validation
+ if (!partnershipData.name || !partnershipData.yearFrom) {
+ return NextResponse.json(
+ { error: 'Name and starting year are required' },
+ { status: 400 }
+ );
+ }
+
+ // Validate year range
+ if (partnershipData.yearTo && partnershipData.yearTo < partnershipData.yearFrom) {
+ return NextResponse.json(
+ { error: 'End year must be greater than or equal to start year' },
+ { status: 400 }
+ );
+ }
+
+ const partnership = await partnershipsRepo.update(params.id, partnershipData);
+
+ if (!partnership) {
+ return NextResponse.json({ error: 'Partnership not found' }, { status: 404 });
+ }
+
+ // Revalidate homepage
+ revalidatePath('/');
+
+ return NextResponse.json({ partnership });
+ } catch (error: any) {
+ console.error('Error updating partnership:', error);
+ return NextResponse.json({ error: 'Failed to update partnership' }, { status: 500 });
+ }
+}
+
+// DELETE /api/admin/partnerships/[id] - Delete partnership
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ // Validate authentication
+ const auth = await requireAdminAuth(request);
+ if (!auth.isAuthenticated) {
+ return NextResponse.json({ error: auth.error }, { status: 401 });
+ }
+
+ try {
+ await partnershipsRepo.delete(params.id);
+
+ // Revalidate homepage
+ revalidatePath('/');
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error('Error deleting partnership:', error);
+ return NextResponse.json({ error: 'Failed to delete partnership' }, { status: 500 });
+ }
+}
\ No newline at end of file
diff --git a/frontend/app/api/admin/partnerships/bulk/route.ts b/frontend/app/api/admin/partnerships/bulk/route.ts
new file mode 100644
index 0000000..a86588f
--- /dev/null
+++ b/frontend/app/api/admin/partnerships/bulk/route.ts
@@ -0,0 +1,102 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAdminAuth } from '@/lib/auth';
+import { partnershipsRepo } from '@/lib/repositories/partnerships';
+import { revalidatePath } from 'next/cache';
+
+// POST /api/admin/partnerships/bulk - Bulk operations on partnerships
+export async function POST(request: NextRequest) {
+ // Validate authentication
+ const auth = await requireAdminAuth(request);
+ if (!auth.isAuthenticated) {
+ return NextResponse.json({ error: auth.error }, { status: 401 });
+ }
+
+ try {
+ const { action, partnershipIds, data } = await request.json();
+
+ if (!action || !partnershipIds || !Array.isArray(partnershipIds)) {
+ return NextResponse.json(
+ { error: 'Action and partnershipIds array are required' },
+ { status: 400 }
+ );
+ }
+
+ const results = {
+ success: [] as string[],
+ errors: [] as { id: string; error: string }[],
+ };
+
+ switch (action) {
+ case 'delete':
+ for (const partnershipId of partnershipIds) {
+ try {
+ await partnershipsRepo.delete(partnershipId);
+ results.success.push(partnershipId);
+ } catch (error) {
+ results.errors.push({
+ id: partnershipId,
+ error: error instanceof Error ? error.message : 'Failed to delete',
+ });
+ }
+ }
+ break;
+
+ case 'updateStatus':
+ if (data?.active === undefined) {
+ return NextResponse.json(
+ { error: 'Status (active) is required for updateStatus action' },
+ { status: 400 }
+ );
+ }
+
+ for (const partnershipId of partnershipIds) {
+ try {
+ await partnershipsRepo.update(partnershipId, { active: data.active });
+ results.success.push(partnershipId);
+ } catch (error) {
+ results.errors.push({
+ id: partnershipId,
+ error: error instanceof Error ? error.message : 'Failed to update status',
+ });
+ }
+ }
+ break;
+
+ case 'updateDisplayOrder':
+ if (!data?.updates || !Array.isArray(data.updates)) {
+ return NextResponse.json(
+ { error: 'Updates array is required for updateDisplayOrder action' },
+ { status: 400 }
+ );
+ }
+
+ try {
+ await partnershipsRepo.updateDisplayOrders(data.updates);
+ results.success.push(...partnershipIds);
+ } catch (error) {
+ return NextResponse.json(
+ { error: 'Failed to update display orders' },
+ { status: 500 }
+ );
+ }
+ break;
+
+ default:
+ return NextResponse.json(
+ { error: `Unknown action: ${action}` },
+ { status: 400 }
+ );
+ }
+
+ // Revalidate homepage
+ revalidatePath('/');
+
+ return NextResponse.json({ results });
+ } catch (error) {
+ console.error('Error in bulk partnership operation:', error);
+ return NextResponse.json(
+ { error: 'Failed to perform bulk operation' },
+ { status: 500 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/frontend/app/api/admin/partnerships/route.ts b/frontend/app/api/admin/partnerships/route.ts
new file mode 100644
index 0000000..0901cf2
--- /dev/null
+++ b/frontend/app/api/admin/partnerships/route.ts
@@ -0,0 +1,60 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAdminAuth } from '@/lib/auth';
+import { partnershipsRepo } from '@/lib/repositories/partnerships';
+import { revalidatePath } from 'next/cache';
+
+// GET /api/admin/partnerships - List all partnerships
+export async function GET(request: NextRequest) {
+ // Validate authentication
+ const auth = await requireAdminAuth(request);
+ if (!auth.isAuthenticated) {
+ return NextResponse.json({ error: auth.error }, { status: 401 });
+ }
+
+ try {
+ const partnerships = await partnershipsRepo.findAll();
+ return NextResponse.json({ partnerships });
+ } catch (error) {
+ console.error('Error fetching partnerships:', error);
+ return NextResponse.json({ error: 'Failed to fetch partnerships' }, { status: 500 });
+ }
+}
+
+// POST /api/admin/partnerships - Create new partnership
+export async function POST(request: NextRequest) {
+ // Validate authentication
+ const auth = await requireAdminAuth(request);
+ if (!auth.isAuthenticated) {
+ return NextResponse.json({ error: auth.error }, { status: 401 });
+ }
+
+ try {
+ const partnershipData = await request.json();
+
+ // Basic validation
+ if (!partnershipData.name || !partnershipData.yearFrom) {
+ return NextResponse.json(
+ { error: 'Name and starting year are required' },
+ { status: 400 }
+ );
+ }
+
+ // Validate year range
+ if (partnershipData.yearTo && partnershipData.yearTo < partnershipData.yearFrom) {
+ return NextResponse.json(
+ { error: 'End year must be greater than or equal to start year' },
+ { status: 400 }
+ );
+ }
+
+ const partnership = await partnershipsRepo.create(partnershipData);
+
+ // Revalidate homepage
+ revalidatePath('/');
+
+ return NextResponse.json({ partnership }, { status: 201 });
+ } catch (error: any) {
+ console.error('Error creating partnership:', error);
+ return NextResponse.json({ error: 'Failed to create partnership' }, { status: 500 });
+ }
+}
\ No newline at end of file
diff --git a/frontend/app/api/admin/projects/[id]/members/route.ts b/frontend/app/api/admin/projects/[id]/members/route.ts
new file mode 100644
index 0000000..38f127f
--- /dev/null
+++ b/frontend/app/api/admin/projects/[id]/members/route.ts
@@ -0,0 +1,160 @@
+import { NextRequest, NextResponse } from 'next/server';
+import dbConnect from '@/lib/mongodb';
+import ProjectMember from '@/lib/models/ProjectMember';
+import Project from '@/lib/models/Project';
+import BoardMember from '@/lib/models/BoardMember';
+
+interface RouteParams {
+ params: {
+ id: string;
+ };
+}
+
+// GET /api/admin/projects/[id]/members - Get all members for a project
+export async function GET(request: NextRequest, { params }: RouteParams) {
+ try {
+ await dbConnect();
+
+ const projectId = params.id;
+
+ // Verify project exists
+ const project = await Project.findById(projectId);
+ if (!project) {
+ return NextResponse.json({ error: 'Project not found' }, { status: 404 });
+ }
+
+ // Get project members with populated member data
+ const projectMembers = await ProjectMember.find({ projectId })
+ .populate({
+ path: 'memberId',
+ select: 'name role roleType photoUrl photoBase64 bio socials displayOrder active'
+ })
+ .sort({ joinedAt: 1 })
+ .lean();
+
+ const formattedMembers = projectMembers.map((pm: any) => ({
+ id: pm._id.toString(),
+ projectId: pm.projectId.toString(),
+ memberId: pm.memberId._id.toString(),
+ role: pm.role as 'member' | 'coordinator',
+ joinedAt: pm.joinedAt.toISOString(),
+ member: {
+ id: pm.memberId._id.toString(),
+ name: pm.memberId.name,
+ role: pm.memberId.role,
+ roleType: pm.memberId.roleType,
+ photoUrl: pm.memberId.photoUrl || '',
+ photoBase64: pm.memberId.photoBase64 || '',
+ bio: pm.memberId.bio || '',
+ socials: pm.memberId.socials || '',
+ displayOrder: pm.memberId.displayOrder,
+ active: pm.memberId.active,
+ }
+ }));
+
+ return NextResponse.json({ members: formattedMembers });
+ } catch (error) {
+ console.error('Error fetching project members:', error);
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
+ }
+}
+
+// POST /api/admin/projects/[id]/members - Add member to project
+export async function POST(request: NextRequest, { params }: RouteParams) {
+ try {
+ await dbConnect();
+
+ const projectId = params.id;
+ const { memberId, role } = await request.json();
+
+ if (!memberId) {
+ return NextResponse.json({ error: 'Member ID is required' }, { status: 400 });
+ }
+
+ if (!role || !['member', 'coordinator'].includes(role)) {
+ return NextResponse.json({ error: 'Role is required and must be either "member" or "coordinator"' }, { status: 400 });
+ }
+
+ // Verify project and member exist
+ const [project, member] = await Promise.all([
+ Project.findById(projectId),
+ BoardMember.findById(memberId)
+ ]);
+
+ if (!project) {
+ return NextResponse.json({ error: 'Project not found' }, { status: 404 });
+ }
+
+ if (!member) {
+ return NextResponse.json({ error: 'Member not found' }, { status: 404 });
+ }
+
+ // Check if assignment already exists
+ const existingAssignment = await ProjectMember.findOne({ projectId, memberId });
+ if (existingAssignment) {
+ return NextResponse.json({ error: 'Member is already assigned to this project' }, { status: 409 });
+ }
+
+ // Create new assignment
+ const projectMember = new ProjectMember({
+ projectId,
+ memberId,
+ role: role as 'member' | 'coordinator',
+ });
+
+ const saved = await projectMember.save();
+
+ // Return the formatted assignment
+ const formattedAssignment = {
+ id: saved._id.toString(),
+ projectId: saved.projectId.toString(),
+ memberId: saved.memberId.toString(),
+ role: saved.role as 'member' | 'coordinator',
+ joinedAt: saved.joinedAt.toISOString(),
+ member: {
+ id: (member as any)._id.toString(),
+ name: member.name,
+ role: member.role,
+ roleType: member.roleType,
+ photoUrl: member.photoUrl || '',
+ photoBase64: member.photoBase64 || '',
+ bio: member.bio || '',
+ socials: member.socials || '',
+ displayOrder: member.displayOrder,
+ active: member.active,
+ }
+ };
+
+ return NextResponse.json({ assignment: formattedAssignment }, { status: 201 });
+ } catch (error) {
+ console.error('Error adding member to project:', error);
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
+ }
+}
+
+// DELETE /api/admin/projects/[id]/members - Remove member from project
+export async function DELETE(request: NextRequest, { params }: RouteParams) {
+ try {
+ await dbConnect();
+
+ const projectId = params.id;
+ const { searchParams } = new URL(request.url);
+ const memberId = searchParams.get('memberId');
+
+ if (!memberId) {
+ return NextResponse.json({ error: 'Member ID is required' }, { status: 400 });
+ }
+
+ // Remove the assignment
+ const result = await ProjectMember.findOneAndDelete({ projectId, memberId });
+
+ if (!result) {
+ return NextResponse.json({ error: 'Assignment not found' }, { status: 404 });
+ }
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error('Error removing member from project:', error);
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
+ }
+}
\ No newline at end of file
diff --git a/frontend/app/api/admin/projects/[id]/route.ts b/frontend/app/api/admin/projects/[id]/route.ts
new file mode 100644
index 0000000..a1ae444
--- /dev/null
+++ b/frontend/app/api/admin/projects/[id]/route.ts
@@ -0,0 +1,126 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAdminAuth } from '@/lib/auth';
+import { projectsRepo } from '@/lib/repositories';
+import { revalidatePath } from 'next/cache';
+
+// GET /api/admin/projects/[id] - Get project by ID
+export async function GET(
+ request: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ // Validate authentication
+ const auth = await requireAdminAuth(request);
+ if (!auth.isAuthenticated) {
+ return NextResponse.json({ error: auth.error }, { status: 401 });
+ }
+
+ try {
+ const project = await projectsRepo.findById(params.id);
+
+ if (!project) {
+ return NextResponse.json({ error: 'Project not found' }, { status: 404 });
+ }
+
+ return NextResponse.json({ project });
+ } catch (error) {
+ console.error('Error fetching project:', error);
+ return NextResponse.json({ error: 'Failed to fetch project' }, { status: 500 });
+
+ }
+}
+
+// PUT /api/admin/projects/[id] - Update project
+export async function PUT(
+ request: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ // Validate authentication
+ const auth = await requireAdminAuth(request);
+ if (!auth.isAuthenticated) {
+ return NextResponse.json({ error: auth.error }, { status: 401 });
+ }
+
+ try {
+ const projectData = await request.json();
+
+ // Import validation function
+ const { validateProject } = await import('@/lib/validation');
+
+ // Validate the project data
+ const validation = validateProject(projectData);
+ if (!validation.isValid) {
+ return NextResponse.json(
+ {
+ error: 'Validation failed',
+ details: validation.errors,
+ fieldErrors: validation.fieldErrors
+ },
+ { status: 400 }
+ );
+ }
+
+ // Additional server-side checks for slug uniqueness
+ if (projectData.slug) {
+ const existingProject = await projectsRepo.findBySlug(projectData.slug);
+ if (existingProject && existingProject.id !== params.id) {
+ return NextResponse.json(
+ { error: 'A project with this URL slug already exists' },
+ { status: 409 }
+ );
+ }
+ }
+
+ const project = await projectsRepo.update(params.id, projectData);
+
+ // Revalidate pages that display projects
+ revalidatePath('/projects');
+ revalidatePath('/');
+ revalidatePath('/api/projects');
+ revalidatePath(`/projects/${project.slug}`); // Also revalidate the specific project page
+
+ return NextResponse.json({ project });
+ } catch (error: any) {
+ console.error('Error updating project:', error);
+
+ if (error.message?.includes('not found')) {
+ return NextResponse.json({ error: 'Project not found' }, { status: 404 });
+ }
+
+ if (error.message?.includes('already exists')) {
+ return NextResponse.json({ error: error.message }, { status: 409 });
+ }
+
+ return NextResponse.json({ error: 'Failed to update project' }, { status: 500 });
+ }
+}
+
+// DELETE /api/admin/projects/[id] - Delete project
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: { id: string } }
+) {
+ // Validate authentication
+ const auth = await requireAdminAuth(request);
+ if (!auth.isAuthenticated) {
+ return NextResponse.json({ error: auth.error }, { status: 401 });
+ }
+
+ try {
+ await projectsRepo.delete(params.id);
+
+ // Revalidate pages that display projects
+ revalidatePath('/projects');
+ revalidatePath('/');
+ revalidatePath('/api/projects');
+
+ return NextResponse.json({ message: 'Project deleted successfully' });
+ } catch (error: any) {
+ console.error('Error deleting project:', error);
+
+ if (error.message?.includes('not found')) {
+ return NextResponse.json({ error: 'Project not found' }, { status: 404 });
+ }
+
+ return NextResponse.json({ error: 'Failed to delete project' }, { status: 500 });
+ }
+}
\ No newline at end of file
diff --git a/frontend/app/api/admin/projects/bulk/route.ts b/frontend/app/api/admin/projects/bulk/route.ts
new file mode 100644
index 0000000..037b266
--- /dev/null
+++ b/frontend/app/api/admin/projects/bulk/route.ts
@@ -0,0 +1,120 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAdminAuth } from '@/lib/auth';
+import { projectsRepo } from '@/lib/repositories';
+import { revalidatePath } from 'next/cache';
+
+// POST /api/admin/projects/bulk - Bulk operations on projects
+export async function POST(request: NextRequest) {
+ // Validate authentication
+ const auth = await requireAdminAuth(request);
+ if (!auth.isAuthenticated) {
+ return NextResponse.json({ error: auth.error }, { status: 401 });
+ }
+
+ try {
+ const { action, projectIds, data } = await request.json();
+
+ if (!action || !projectIds || !Array.isArray(projectIds)) {
+ return NextResponse.json(
+ { error: 'Action and projectIds array are required' },
+ { status: 400 }
+ );
+ }
+
+ if (projectIds.length === 0) {
+ return NextResponse.json(
+ { error: 'No projects selected' },
+ { status: 400 }
+ );
+ }
+
+ const results = {
+ success: [] as string[],
+ errors: [] as { id: string; error: string }[],
+ };
+
+ switch (action) {
+ case 'delete':
+ for (const projectId of projectIds) {
+ try {
+ await projectsRepo.delete(projectId);
+ results.success.push(projectId);
+ } catch (error) {
+ results.errors.push({
+ id: projectId,
+ error: error instanceof Error ? error.message : 'Failed to delete',
+ });
+ }
+ }
+ break;
+
+ case 'updateStatus':
+ if (!data?.status) {
+ return NextResponse.json(
+ { error: 'Status is required for updateStatus action' },
+ { status: 400 }
+ );
+ }
+
+ for (const projectId of projectIds) {
+ try {
+ await projectsRepo.update(projectId, { status: data.status });
+ results.success.push(projectId);
+ } catch (error) {
+ results.errors.push({
+ id: projectId,
+ error: error instanceof Error ? error.message : 'Failed to update status',
+ });
+ }
+ }
+ break;
+
+ case 'updateDisplayOrder':
+ if (!data?.updates || !Array.isArray(data.updates)) {
+ return NextResponse.json(
+ { error: 'Updates array is required for updateDisplayOrder action' },
+ { status: 400 }
+ );
+ }
+
+ for (const update of data.updates) {
+ try {
+ await projectsRepo.update(update.id, { displayOrder: update.displayOrder });
+ results.success.push(update.id);
+ } catch (error) {
+ results.errors.push({
+ id: update.id,
+ error: error instanceof Error ? error.message : 'Failed to update display order',
+ });
+ }
+ }
+ break;
+
+ default:
+ return NextResponse.json(
+ { error: `Unknown action: ${action}` },
+ { status: 400 }
+ );
+ }
+
+ // Revalidate pages that display projects if any operations succeeded
+ if (results.success.length > 0) {
+ revalidatePath('/projects');
+ revalidatePath('/');
+ revalidatePath('/api/projects');
+ }
+
+ return NextResponse.json({
+ message: `Bulk ${action} completed`,
+ results,
+ summary: {
+ total: projectIds.length,
+ successful: results.success.length,
+ failed: results.errors.length,
+ },
+ });
+ } catch (error) {
+ console.error('Error processing bulk operation:', error);
+ return NextResponse.json({ error: 'Failed to process bulk operation' }, { status: 500 });
+ }
+}
\ No newline at end of file
diff --git a/frontend/app/api/admin/projects/route.ts b/frontend/app/api/admin/projects/route.ts
new file mode 100644
index 0000000..9577c24
--- /dev/null
+++ b/frontend/app/api/admin/projects/route.ts
@@ -0,0 +1,84 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAdminAuth } from '@/lib/auth';
+import { projectsRepo } from '@/lib/repositories';
+import { revalidatePath } from 'next/cache';
+
+// GET /api/admin/projects - List all projects
+export async function GET(request: NextRequest) {
+ // Validate authentication
+ const auth = await requireAdminAuth(request);
+ if (!auth.isAuthenticated) {
+ return NextResponse.json({ error: auth.error }, { status: 401 });
+ }
+
+ try {
+ const projects = await projectsRepo.findAll();
+ return NextResponse.json({ projects });
+ } catch (error) {
+ console.error('Error fetching projects:', error);
+ return NextResponse.json({ error: 'Failed to fetch projects' }, { status: 500 });
+ }
+}
+
+// POST /api/admin/projects - Create new project
+export async function POST(request: NextRequest) {
+ // Validate authentication
+ const auth = await requireAdminAuth(request);
+ if (!auth.isAuthenticated) {
+ return NextResponse.json({ error: auth.error }, { status: 401 });
+ }
+
+ try {
+ const projectData = await request.json();
+
+ // Import validation function
+ const { validateProject } = await import('@/lib/validation');
+
+ // Validate the project data
+ const validation = validateProject(projectData);
+ if (!validation.isValid) {
+ return NextResponse.json(
+ {
+ error: 'Validation failed',
+ details: validation.errors,
+ fieldErrors: validation.fieldErrors
+ },
+ { status: 400 }
+ );
+ }
+
+ // Generate slug if not provided
+ if (!projectData.slug) {
+ projectData.slug = await projectsRepo.generateSlug(projectData.title);
+ }
+
+ // Additional server-side checks
+ if (projectData.slug) {
+ // Check if slug already exists
+ const existingProject = await projectsRepo.findBySlug(projectData.slug);
+ if (existingProject) {
+ return NextResponse.json(
+ { error: 'A project with this URL slug already exists' },
+ { status: 409 }
+ );
+ }
+ }
+
+ const project = await projectsRepo.create(projectData);
+
+ // Revalidate pages that display projects
+ revalidatePath('/projects');
+ revalidatePath('/');
+ revalidatePath('/api/projects');
+
+ return NextResponse.json({ project }, { status: 201 });
+ } catch (error: any) {
+ console.error('Error creating project:', error);
+
+ if (error.message?.includes('already exists')) {
+ return NextResponse.json({ error: error.message }, { status: 409 });
+ }
+
+ return NextResponse.json({ error: 'Failed to create project' }, { status: 500 });
+ }
+}
\ No newline at end of file
diff --git a/frontend/app/api/board/route.ts b/frontend/app/api/board/route.ts
new file mode 100644
index 0000000..4e3f9db
--- /dev/null
+++ b/frontend/app/api/board/route.ts
@@ -0,0 +1,18 @@
+import { NextResponse } from 'next/server';
+import { boardMembersRepo } from '@/lib/repositories';
+
+// Set revalidation to 60 seconds to ensure fresh data
+export const revalidate = 60;
+
+export async function GET() {
+ try {
+ const boardMembers = await boardMembersRepo.findActive();
+ return NextResponse.json(boardMembers);
+ } catch (error) {
+ console.error('Error fetching board members:', error);
+ return NextResponse.json(
+ { error: 'Failed to fetch board members' },
+ { status: 500 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/frontend/app/api/projects/route.ts b/frontend/app/api/projects/route.ts
new file mode 100644
index 0000000..8e39b26
--- /dev/null
+++ b/frontend/app/api/projects/route.ts
@@ -0,0 +1,18 @@
+import { NextResponse } from 'next/server';
+import { projectsRepo } from '@/lib/repositories';
+
+// Set revalidation to 60 seconds to ensure fresh data
+export const revalidate = 60;
+
+export async function GET() {
+ try {
+ const projects = await projectsRepo.findAll();
+ return NextResponse.json(projects);
+ } catch (error) {
+ console.error('Error fetching projects:', error);
+ return NextResponse.json(
+ { error: 'Failed to fetch projects' },
+ { status: 500 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/frontend/app/board/[id]/page.tsx b/frontend/app/board/[id]/page.tsx
new file mode 100644
index 0000000..a0d3105
--- /dev/null
+++ b/frontend/app/board/[id]/page.tsx
@@ -0,0 +1,291 @@
+import { boardMembersRepo } from "@/lib/repositories";
+import type { BoardMember, Project } from "@/lib/types";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { cn } from "@/lib/utils";
+import Image from "next/image";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { Metadata } from "next";
+import { ArrowLeft, Mail, Github, Linkedin, Twitter, ExternalLink } from "lucide-react";
+import { Avatar } from "@/components/ui/avatar";
+import dbConnect from "@/lib/mongodb";
+import ProjectMember from "@/lib/models/ProjectMember";
+
+interface MemberPageProps {
+ params: {
+ id: string;
+ };
+}
+
+// Server component to fetch member by ID with projects
+async function getMemberWithProjects(id: string): Promise<{member: BoardMember, projects: any[]} | null> {
+ try {
+ const member = await boardMembersRepo.findById(id);
+ if (!member) return null;
+
+ // Get member's projects
+ await dbConnect();
+ const memberProjects = await ProjectMember.find({ memberId: id })
+ .populate({
+ path: 'projectId',
+ select: 'title slug description imageUrl imageBase64 tags status links displayOrder'
+ })
+ .sort({ joinedAt: 1 })
+ .lean();
+
+ const formattedProjects = memberProjects.map((mp: any) => ({
+ id: mp._id.toString(),
+ projectId: mp.projectId._id.toString(),
+ memberId: mp.memberId.toString(),
+ role: mp.role as 'member' | 'coordinator',
+ joinedAt: mp.joinedAt.toISOString(),
+ project: {
+ id: mp.projectId._id.toString(),
+ title: mp.projectId.title,
+ slug: mp.projectId.slug,
+ description: mp.projectId.description,
+ imageUrl: mp.projectId.imageUrl || '',
+ imageBase64: mp.projectId.imageBase64 || '',
+ tags: mp.projectId.tags || '',
+ status: mp.projectId.status,
+ links: mp.projectId.links || '',
+ displayOrder: mp.projectId.displayOrder,
+ createdAt: mp.projectId.createdAt?.toISOString ? mp.projectId.createdAt.toISOString() : mp.projectId.createdAt,
+ updatedAt: mp.projectId.updatedAt?.toISOString ? mp.projectId.updatedAt.toISOString() : mp.projectId.updatedAt,
+ }
+ }));
+
+ return { member, projects: formattedProjects };
+ } catch (error) {
+ console.error('Failed to fetch member with projects:', error);
+ return null;
+ }
+}
+
+// Helper function to parse JSON socials
+function parseSocials(socials?: string): { [key: string]: string } {
+ if (!socials) return {};
+ try {
+ return JSON.parse(socials);
+ } catch {
+ return {};
+ }
+}
+
+// Helper function to get social media icon
+function getSocialIcon(platform: string) {
+ switch (platform.toLowerCase()) {
+ case 'github':
+ return ;
+ case 'linkedin':
+ return ;
+ case 'twitter':
+ return ;
+ case 'email':
+ return ;
+ default:
+ return ;
+ }
+}
+
+// Helper function to format social URL
+function formatSocialUrl(platform: string, value: string): string {
+ switch (platform.toLowerCase()) {
+ case 'email':
+ return `mailto:${value}`;
+ case 'github':
+ return value.startsWith('http') ? value : `https://github.com/${value}`;
+ case 'linkedin':
+ return value.startsWith('http') ? value : `https://linkedin.com/in/${value}`;
+ case 'twitter':
+ return value.startsWith('http') ? value : `https://twitter.com/${value}`;
+ default:
+ return value.startsWith('http') ? value : `https://${value}`;
+ }
+}
+
+// Generate metadata
+export async function generateMetadata({ params }: MemberPageProps): Promise {
+ const data = await getMemberWithProjects(params.id);
+
+ if (!data) {
+ return {
+ title: "Member Not Found | Gradient Science Club",
+ };
+ }
+
+ return {
+ title: `${data.member.name} | Gradient Science Club`,
+ description: data.member.bio || `Learn more about ${data.member.name}, ${data.member.role} at Gradient Science Club.`,
+ };
+}
+
+export default async function MemberPage({ params }: MemberPageProps) {
+ const data = await getMemberWithProjects(params.id);
+
+ if (!data) {
+ notFound();
+ }
+
+ const { member, projects } = data;
+ const socials = parseSocials(member.socials);
+
+ return (
+
+ {/* Back button */}
+
+
+
+
+ Back to Team
+
+
+
+
+
+ {/* Member Profile */}
+
+
+
+
+
+ {member.name}
+ {member.role}
+
+ {member.bio && (
+
+ {member.bio}
+
+ )}
+
+ {/* Social links */}
+ {Object.keys(socials).length > 0 && (
+
+ {Object.entries(socials)
+ .filter(([_, url]) => url) // Filter out empty values
+ .map(([platform, url]) => (
+
+ {getSocialIcon(platform)}
+
+ ))}
+
+ )}
+
+
+
+
+ {/* Member Projects */}
+
+
+
Projects
+
+ Projects that {member.name} is currently working on or has contributed to.
+
+
+
+ {projects.length > 0 ? (
+
+ {projects.map((assignment) => (
+
+ ))}
+
+ ) : (
+
+
+
+ {member.name} is not currently assigned to any projects.
+
+
+
+ )}
+
+
+
+ );
+}
+
+// Project card component for member page
+function ProjectCard({ assignment }: { assignment: any }) {
+ const project = assignment.project;
+ const tags = project.tags ? project.tags.split(',').map((tag: string) => tag.trim()) : [];
+
+ return (
+
+
+
+
+
+
+
+ {project.title}
+
+ {project.status}
+
+
+
+ {assignment.role}
+
+
+
+
+
+ {project.description || 'No description provided.'}
+
+
+ {/* Tags */}
+ {tags.length > 0 && (
+
+ {tags.slice(0, 2).map((tag: string, index: number) => (
+
+ {tag}
+
+ ))}
+ {tags.length > 2 && (
+
+ +{tags.length - 2}
+
+ )}
+
+ )}
+
+
+
+
+
+ View Project
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/app/board/page.tsx b/frontend/app/board/page.tsx
new file mode 100644
index 0000000..d93d3e2
--- /dev/null
+++ b/frontend/app/board/page.tsx
@@ -0,0 +1,234 @@
+import { boardMembersRepo } from "@/lib/repositories";
+import type { BoardMember, MemberSocials } from "@/lib/types";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import Avatar from "@/components/ui/avatar";
+import Image from "next/image";
+import Link from "next/link";
+import { Metadata } from "next";
+import { Mail, Linkedin, Github, Twitter, Globe, ExternalLink, ArrowLeft } from "lucide-react";
+import urls from "@/public/data/urls.json";
+
+// Revalidate every 60 seconds
+export const revalidate = 60;
+
+export const metadata: Metadata = {
+ title: "Board Members | Gradient Science Club",
+ description: "Meet the passionate leaders driving our science club forward.",
+};
+
+// Server component to fetch active board members grouped by role type
+async function getActiveBoardMembersGrouped(): Promise<{
+ boardMembers: BoardMember[];
+ coordinators: BoardMember[];
+ members: BoardMember[];
+}> {
+ try {
+ return await boardMembersRepo.findGroupedByRoleType(true);
+ } catch (error) {
+ console.error('Failed to fetch board members:', error);
+ return { boardMembers: [], coordinators: [], members: [] };
+ }
+}
+
+// Helper function to parse JSON socials
+function parseSocials(socials?: string): { [key: string]: string } {
+ if (!socials) return {};
+ try {
+ const parsed = JSON.parse(socials);
+ // Filter out empty string values
+ const filtered: { [key: string]: string } = {};
+ Object.entries(parsed).forEach(([key, value]) => {
+ if (value && typeof value === 'string' && value.trim() !== '') {
+ filtered[key] = value as string;
+ }
+ });
+ return filtered;
+ } catch {
+ return {};
+ }
+}
+
+// Helper to get social media icon
+function getSocialIcon(platform: string) {
+ switch (platform.toLowerCase()) {
+ case 'email':
+ return ;
+ case 'linkedin':
+ return ;
+ case 'github':
+ return ;
+ case 'twitter':
+ return ;
+ case 'website':
+ return ;
+ default:
+ return ;
+ }
+}
+
+// Helper to format social media URL
+function formatSocialUrl(platform: string, value: string): string {
+ if (platform === 'email') {
+ return value.includes('@') ? `mailto:${value}` : value;
+ }
+ return value.startsWith('http') ? value : `https://${value}`;
+}
+
+export default async function BoardPage() {
+ const { boardMembers, coordinators, members } = await getActiveBoardMembersGrouped();
+ const totalMembers = boardMembers.length + coordinators.length + members.length;
+
+ return (
+
+ {/* Back to Home Button */}
+
+
+
+
+ Back to Home
+
+
+
+
+ {/* Header */}
+
+
Our Team
+
+ Meet the passionate students leading our science club and driving
+ innovation in research, education, and collaboration.
+
+
+
+ {/* Board Members */}
+ {boardMembers.length > 0 && (
+
+ Board Members
+
+ Our official board members who hold formal positions and oversee club operations.
+
+
+ {boardMembers
+ .sort((a, b) => a.displayOrder - b.displayOrder)
+ .map((member) => (
+
+ ))}
+
+
+ )}
+
+ {/* Coordinators */}
+ {coordinators.length > 0 && (
+
+ Coordinators
+
+ Our dedicated coordinators who manage specific areas and initiatives within the club.
+
+
+ {coordinators
+ .sort((a, b) => a.displayOrder - b.displayOrder)
+ .map((member) => (
+
+ ))}
+
+
+ )}
+
+ {/* Members */}
+ {members.length > 0 && (
+
+ Members
+
+ Our active members who participate in club activities and contribute to our mission.
+
+
+ {members
+ .sort((a, b) => a.displayOrder - b.displayOrder)
+ .map((member) => (
+
+ ))}
+
+
+ )}
+
+ {/* No members message */}
+ {totalMembers === 0 && (
+
+
+ Team member information will be available soon.
+
+
+ )}
+
+ {/* Call to action */}
+
+
Interested in Joining Our Team?
+
+ We're always looking for passionate students to join our team and help
+ lead exciting scientific initiatives. Contact us to learn about opportunities!
+
+
+
+ Contact Us on Facebook
+
+
+
+
+ );
+}
+
+// Board member card component
+function BoardMemberCard({ member, featured = false }: { member: BoardMember; featured?: boolean }) {
+ const socials = parseSocials(member.socials);
+
+ return (
+
+
+
+
+
+ {member.name}
+ {member.role && (
+
+ {member.role}
+
+ )}
+
+
+
+ {member.bio && (
+
+ {member.bio}
+
+ )}
+
+ {/* Social links */}
+ {Object.keys(socials).length > 0 && (
+
+ {Object.entries(socials)
+ .filter(([_, url]) => url) // Filter out empty values
+ .slice(0, 4) // Limit to 4 social links
+ .map(([platform, url]) => (
+
+ {getSocialIcon(platform)}
+
+ ))}
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index b5c61c9..eb57c0e 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -1,3 +1,38 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+
+/* Critical styles to prevent layout shifts on refresh */
+@layer base {
+ html {
+ scroll-behavior: smooth;
+ }
+
+ body {
+ margin: 0;
+ padding: 0;
+ min-height: 100vh;
+ }
+
+ /* Ensure consistent loading */
+ * {
+ box-sizing: border-box;
+ }
+}
+
+/* Ensure proper hydration */
+@layer components {
+ .container {
+ width: 100%;
+ margin-left: auto;
+ margin-right: auto;
+ padding-left: 2rem;
+ padding-right: 2rem;
+ }
+
+ @media (min-width: 1400px) {
+ .container {
+ max-width: 1400px;
+ }
+ }
+}
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
new file mode 100644
index 0000000..6a4653c
--- /dev/null
+++ b/frontend/app/layout.tsx
@@ -0,0 +1,30 @@
+import type { Metadata } from "next";
+import { Lato } from "next/font/google";
+import React from "react";
+import "./globals.css";
+
+const lato = Lato({
+ subsets: ["latin"],
+ weight: ["300", "400", "700", "900"],
+ display: 'swap',
+});
+
+export const metadata: Metadata = {
+ metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'),
+ title: "Gradient Science Club",
+ description: "Koło naukowe Gradient - Science Club focused on innovative research and education",
+};
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/app/projects/[slug]/page.tsx b/frontend/app/projects/[slug]/page.tsx
new file mode 100644
index 0000000..f075b39
--- /dev/null
+++ b/frontend/app/projects/[slug]/page.tsx
@@ -0,0 +1,273 @@
+import { projectsRepo } from "@/lib/repositories";
+import type { Project } from "@/lib/types";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { cn } from "@/lib/utils";
+import Image from "next/image";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { Metadata } from "next";
+import { ArrowLeft, ExternalLink, Github, Globe, FileText } from "lucide-react";
+import { Avatar } from "@/components/ui/avatar";
+
+interface ProjectPageProps {
+ params: {
+ slug: string;
+ };
+}
+
+// Server component to fetch project by slug with members
+async function getProject(slug: string): Promise {
+ try {
+ return await projectsRepo.findBySlugWithMembers(slug);
+ } catch (error) {
+ console.error('Failed to fetch project with members:', error);
+ // Fallback to project without members
+ try {
+ return await projectsRepo.findBySlug(slug);
+ } catch (fallbackError) {
+ console.error('Failed to fetch project (fallback):', fallbackError);
+ return null;
+ }
+ }
+}
+
+// Generate metadata for SEO
+export async function generateMetadata({ params }: ProjectPageProps): Promise {
+ const project = await getProject(params.slug);
+
+ if (!project) {
+ return {
+ title: "Project Not Found",
+ };
+ }
+
+ return {
+ title: `${project.title} | Gradient Science Club`,
+ description: project.description || `Learn more about ${project.title} from Gradient Science Club`,
+ openGraph: {
+ title: project.title,
+ description: project.description || `Learn more about ${project.title} from Gradient Science Club`,
+ images: project.imageUrl ? [project.imageUrl] : [],
+ },
+ };
+}
+
+// Helper function to parse JSON links
+function parseLinks(links?: string): { [key: string]: string } {
+ if (!links) return {};
+ try {
+ return JSON.parse(links);
+ } catch {
+ return {};
+ }
+}
+
+// Helper to get link icon
+function getLinkIcon(type: string) {
+ switch (type.toLowerCase()) {
+ case 'github':
+ return ;
+ case 'website':
+ case 'demo':
+ return ;
+ case 'paper':
+ case 'materials':
+ case 'resources':
+ return ;
+ default:
+ return ;
+ }
+}
+
+export default async function ProjectPage({ params }: ProjectPageProps) {
+ const project = await getProject(params.slug);
+
+ if (!project) {
+ notFound();
+ }
+
+ const links = parseLinks(project.links);
+ const tags = project.tags ? project.tags.split(',').map(tag => tag.trim()) : [];
+
+ return (
+
+ {/* Back button */}
+
+
+
+
+ Back to Projects
+
+
+
+
+
+ {/* Main content */}
+
+ {/* Header */}
+
+
+
{project.title}
+
+ {project.status}
+
+
+
+ {/* Tags */}
+ {tags.length > 0 && (
+
+ {tags.map((tag, index) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+
+ {/* Featured image */}
+ {(project.imageBase64 || project.imageUrl) && (
+
+
+
+ )}
+
+ {/* Description */}
+
+
+ About This Project
+
+ {project.description ? (
+
+ {project.description}
+
+ ) : (
+
+ No description provided for this project.
+
+ )}
+
+
+
+
+ {/* Sidebar */}
+
+ {/* Project Info */}
+
+
+ Project Information
+
+
+
Status
+
{project.status}
+
+
+
Created
+
+ {new Date(project.createdAt).toLocaleDateString()}
+
+
+
+
Last Updated
+
+ {new Date(project.updatedAt).toLocaleDateString()}
+
+
+
+
+
+
+ {/* Links */}
+ {Object.keys(links).length > 0 && (
+
+
+ Related Links
+
+
+
+ )}
+
+ {/* Team Members */}
+ {project.members && project.members.length > 0 && (
+
+
+ Team Members
+
+ {project.members.map((assignment) => (
+
+
+
+
+ {assignment.member?.name}
+
+
+ {assignment.role}
+
+ {assignment.member?.role && (
+
+ {assignment.member.role}
+
+ )}
+
+
+ ))}
+
+
+
+ )}
+
+ {/* Call to action */}
+
+
+ Interested in this project?
+
+ Join our science club to participate in exciting projects like this one.
+
+
+ Contact Us
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/app/projects/page.tsx b/frontend/app/projects/page.tsx
new file mode 100644
index 0000000..29a4930
--- /dev/null
+++ b/frontend/app/projects/page.tsx
@@ -0,0 +1,229 @@
+import { projectsRepo } from "@/lib/repositories";
+import type { Project } from "@/lib/types";
+import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import Image from "next/image";
+import Link from "next/link";
+import { Metadata } from "next";
+import { ArrowLeft } from "lucide-react";
+import { Avatar } from "@/components/ui/avatar";
+
+// Revalidate every 60 seconds
+export const revalidate = 60;
+
+export const metadata: Metadata = {
+ title: "Projects | Gradient Science Club",
+ description: "Explore our current and completed research projects, workshops, and initiatives.",
+};
+
+// Server component to fetch all projects with members
+async function getAllProjects(): Promise {
+ try {
+ return await projectsRepo.findAllWithMembers();
+ } catch (error) {
+ console.error('Failed to fetch projects with members:', error);
+ // Fallback to projects without members
+ try {
+ return await projectsRepo.findAll();
+ } catch (fallbackError) {
+ console.error('Failed to fetch projects (fallback):', fallbackError);
+ return [];
+ }
+ }
+}
+
+// Helper function to parse JSON links
+function parseLinks(links?: string): { [key: string]: string } {
+ if (!links) return {};
+ try {
+ return JSON.parse(links);
+ } catch {
+ return {};
+ }
+}
+
+export default async function ProjectsPage() {
+ const projects = await getAllProjects();
+
+ // Group projects by status
+ const activeProjects = projects.filter(p => p.status === 'active');
+ const plannedProjects = projects.filter(p => p.status === 'planned');
+ const completedProjects = projects.filter(p => p.status === 'completed');
+
+ return (
+
+ {/* Back to Home Button */}
+
+
+
+
+ Back to Home
+
+
+
+
+ {/* Header */}
+
+
Our Projects
+
+ Discover the innovative research projects, workshops, and initiatives
+ that drive our science club forward.
+
+
+
+ {/* Active Projects */}
+ {activeProjects.length > 0 && (
+
+ Active Projects
+
+ {activeProjects.map((project) => (
+
+ ))}
+
+
+ )}
+
+ {/* Planned Projects */}
+ {plannedProjects.length > 0 && (
+
+ Planned Projects
+
+ {plannedProjects.map((project) => (
+
+ ))}
+
+
+ )}
+
+ {/* Completed Projects */}
+ {completedProjects.length > 0 && (
+
+ Completed Projects
+
+ {completedProjects.map((project) => (
+
+ ))}
+
+
+ )}
+
+ {/* No projects message */}
+ {projects.length === 0 && (
+
+
+ No projects available at the moment. Check back soon!
+
+
+ )}
+
+ );
+}
+
+// Project card component
+function ProjectCard({ project }: { project: Project }) {
+ const links = parseLinks(project.links);
+ const tags = project.tags ? project.tags.split(',').map(tag => tag.trim()) : [];
+
+ return (
+
+
+
+
+
+
+
+ {project.title}
+
+ {project.status}
+
+
+
+
+
+
+ {project.description || 'No description provided.'}
+
+
+ {/* Tags */}
+ {tags.length > 0 && (
+
+ {tags.slice(0, 3).map((tag, index) => (
+
+ {tag}
+
+ ))}
+ {tags.length > 3 && (
+
+ +{tags.length - 3}
+
+ )}
+
+ )}
+
+ {/* Links */}
+ {Object.keys(links).length > 0 && (
+
+ {Object.entries(links).slice(0, 2).map(([type, url]) => (
+
+ {type}
+
+ ))}
+
+ )}
+
+ {/* Team Members */}
+ {project.members && project.members.length > 0 && (
+
+
Team:
+
+ {project.members.slice(0, 4).map((assignment) => (
+
+ ))}
+ {project.members.length > 4 && (
+
+ +{project.members.length - 4}
+
+ )}
+
+
+ )}
+
+
+
+
+
+ View Details
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/components/footer.tsx b/frontend/components/footer.tsx
index 5499ee6..d457b07 100644
--- a/frontend/components/footer.tsx
+++ b/frontend/components/footer.tsx
@@ -13,7 +13,7 @@ interface FooterProps extends React.HTMLProps {}
const Footer: React.FC = ({ ...props }) => {
return (
-
+
{
- return (
-
- Made with ❤️ by{" "}
-
- Kamil Kozioł
-
-
- );
-};
-
-export default Plug;
diff --git a/frontend/components/ui/alert-dialog.tsx b/frontend/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..b12d93d
--- /dev/null
+++ b/frontend/components/ui/alert-dialog.tsx
@@ -0,0 +1,141 @@
+"use client"
+
+import * as React from "react"
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
+
+import { cn } from "@/lib/utils"
+import { buttonVariants } from "@/components/ui/button"
+
+const AlertDialog = AlertDialogPrimitive.Root
+
+const AlertDialogTrigger = AlertDialogPrimitive.Trigger
+
+const AlertDialogPortal = AlertDialogPrimitive.Portal
+
+const AlertDialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
+
+const AlertDialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+))
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
+
+const AlertDialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+AlertDialogHeader.displayName = "AlertDialogHeader"
+
+const AlertDialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+AlertDialogFooter.displayName = "AlertDialogFooter"
+
+const AlertDialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
+
+const AlertDialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogDescription.displayName =
+ AlertDialogPrimitive.Description.displayName
+
+const AlertDialogAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
+
+const AlertDialogCancel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+}
diff --git a/frontend/components/ui/alert.tsx b/frontend/components/ui/alert.tsx
new file mode 100644
index 0000000..8ee604f
--- /dev/null
+++ b/frontend/components/ui/alert.tsx
@@ -0,0 +1,59 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
+ {
+ variants: {
+ variant: {
+ default: "bg-background text-foreground",
+ destructive:
+ "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+const Alert = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & VariantProps
+>(({ className, variant, ...props }, ref) => (
+
+))
+Alert.displayName = "Alert"
+
+const AlertTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+AlertTitle.displayName = "AlertTitle"
+
+const AlertDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+AlertDescription.displayName = "AlertDescription"
+
+export { Alert, AlertTitle, AlertDescription }
\ No newline at end of file
diff --git a/frontend/components/ui/avatar.tsx b/frontend/components/ui/avatar.tsx
new file mode 100644
index 0000000..b79a4c4
--- /dev/null
+++ b/frontend/components/ui/avatar.tsx
@@ -0,0 +1,86 @@
+'use client';
+
+import React, { useState } from 'react';
+import { cn } from '@/lib/utils';
+
+interface AvatarProps {
+ src?: string;
+ base64?: string; // Base64 image takes priority over src
+ name: string;
+ size?: 'sm' | 'md' | 'lg' | 'xl';
+ className?: string;
+ alt?: string;
+}
+
+const sizeClasses = {
+ sm: 'w-8 h-8 text-xs',
+ md: 'w-10 h-10 text-sm',
+ lg: 'w-16 h-16 text-lg',
+ xl: 'w-24 h-24 text-xl',
+};
+
+export function Avatar({ src, base64, name, size = 'md', className, alt }: AvatarProps) {
+ const [imageError, setImageError] = useState(false);
+ const [imageLoaded, setImageLoaded] = useState(false);
+
+ // Get initials from name (first letter of first two words)
+ const getInitials = (name: string): string => {
+ return name
+ .split(' ')
+ .slice(0, 2)
+ .map(word => word.charAt(0))
+ .join('')
+ .toUpperCase();
+ };
+
+ // Generate a background color based on the name
+ const getBackgroundColor = (name: string): string => {
+ const colors = [
+ 'bg-red-500',
+ 'bg-blue-500',
+ 'bg-green-500',
+ 'bg-yellow-500',
+ 'bg-purple-500',
+ 'bg-pink-500',
+ 'bg-indigo-500',
+ 'bg-teal-500',
+ 'bg-orange-500',
+ 'bg-cyan-500',
+ ];
+
+ const index = name.charCodeAt(0) % colors.length;
+ return colors[index];
+ };
+
+ // Use base64 if available, otherwise use src
+ const imageSource = base64 || src;
+ const shouldShowPlaceholder = !imageSource || imageError;
+
+ return (
+
+ {!shouldShowPlaceholder && (
+
setImageError(true)}
+ onLoad={() => setImageLoaded(true)}
+ style={{ display: imageError ? 'none' : 'block' }}
+ />
+ )}
+
+ {shouldShowPlaceholder && (
+
+ {getInitials(name)}
+
+ )}
+
+ );
+}
+
+export default Avatar;
\ No newline at end of file
diff --git a/frontend/components/ui/badge.tsx b/frontend/components/ui/badge.tsx
new file mode 100644
index 0000000..1f8fe28
--- /dev/null
+++ b/frontend/components/ui/badge.tsx
@@ -0,0 +1,36 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center rounded-md border border-neutral-200 px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 dark:border-neutral-800 dark:focus:ring-neutral-300",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-neutral-900 text-neutral-50 shadow hover:bg-neutral-900/80 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50/80",
+ secondary:
+ "border-transparent bg-neutral-100 text-neutral-900 hover:bg-neutral-100/80 dark:bg-neutral-800 dark:text-neutral-50 dark:hover:bg-neutral-800/80",
+ destructive:
+ "border-transparent bg-red-500 text-neutral-50 shadow hover:bg-red-500/80 dark:bg-red-900 dark:text-neutral-50 dark:hover:bg-red-900/80",
+ outline: "text-neutral-950 dark:text-neutral-50",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx
index 95ddeb0..be0dce8 100644
--- a/frontend/components/ui/button.tsx
+++ b/frontend/components/ui/button.tsx
@@ -1,11 +1,11 @@
-import { Slot } from "@radix-ui/react-slot";
-import { cva, type VariantProps } from "class-variance-authority";
-import * as React from "react";
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
-import { cn } from "@/lib/utils";
+import { cn } from "@/lib/utils"
const buttonVariants = cva(
- "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 dark:focus-visible:ring-neutral-300",
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-neutral-300",
{
variants: {
variant: {
@@ -16,9 +16,8 @@ const buttonVariants = cva(
outline:
"border border-neutral-200 bg-white shadow-sm hover:bg-neutral-100 hover:text-neutral-900 dark:border-neutral-800 dark:bg-neutral-950 dark:hover:bg-neutral-800 dark:hover:text-neutral-50",
secondary:
- "bg-secondary text-neutral-100 font-bold shadow-sm hover:bg-secondary/90",
- ghost:
- "hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-neutral-800 dark:hover:text-neutral-50",
+ "bg-neutral-100 text-neutral-900 shadow-sm hover:bg-neutral-100/80 dark:bg-neutral-800 dark:text-neutral-50 dark:hover:bg-neutral-800/80",
+ ghost: "hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-neutral-800 dark:hover:text-neutral-50",
link: "text-neutral-900 underline-offset-4 hover:underline dark:text-neutral-50",
},
size: {
@@ -32,27 +31,27 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
- },
-);
+ }
+)
export interface ButtonProps
extends React.ButtonHTMLAttributes,
VariantProps {
- asChild?: boolean;
+ asChild?: boolean
}
const Button = React.forwardRef(
({ className, variant, size, asChild = false, ...props }, ref) => {
- const Comp = asChild ? Slot : "button";
+ const Comp = asChild ? Slot : "button"
return (
- );
- },
-);
-Button.displayName = "Button";
+ )
+ }
+)
+Button.displayName = "Button"
-export { Button, buttonVariants };
+export { Button, buttonVariants }
diff --git a/frontend/components/ui/checkbox.tsx b/frontend/components/ui/checkbox.tsx
new file mode 100644
index 0000000..c5f1b2e
--- /dev/null
+++ b/frontend/components/ui/checkbox.tsx
@@ -0,0 +1,29 @@
+"use client"
+
+import * as React from "react"
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
+import { cn } from "@/lib/utils"
+import { CheckIcon } from "@radix-ui/react-icons"
+
+const Checkbox = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+
+))
+Checkbox.displayName = CheckboxPrimitive.Root.displayName
+
+export { Checkbox }
diff --git a/frontend/components/ui/dialog.tsx b/frontend/components/ui/dialog.tsx
new file mode 100644
index 0000000..ab2573e
--- /dev/null
+++ b/frontend/components/ui/dialog.tsx
@@ -0,0 +1,121 @@
+"use client"
+
+import * as React from "react"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { cn } from "@/lib/utils"
+import { Cross2Icon } from "@radix-ui/react-icons"
+
+const Dialog = DialogPrimitive.Root
+
+const DialogTrigger = DialogPrimitive.Trigger
+
+const DialogPortal = DialogPrimitive.Portal
+
+const DialogClose = DialogPrimitive.Close
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+))
+DialogContent.displayName = DialogPrimitive.Content.displayName
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+DialogHeader.displayName = "DialogHeader"
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+DialogFooter.displayName = "DialogFooter"
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogTitle.displayName = DialogPrimitive.Title.displayName
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogDescription.displayName = DialogPrimitive.Description.displayName
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogTrigger,
+ DialogClose,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+}
diff --git a/frontend/components/ui/dropdown-menu.tsx b/frontend/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..2021f49
--- /dev/null
+++ b/frontend/components/ui/dropdown-menu.tsx
@@ -0,0 +1,200 @@
+"use client"
+
+import * as React from "react"
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
+import { cn } from "@/lib/utils"
+import { CheckIcon, ChevronRightIcon, DotFilledIcon } from "@radix-ui/react-icons"
+
+const DropdownMenu = DropdownMenuPrimitive.Root
+
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
+
+const DropdownMenuGroup = DropdownMenuPrimitive.Group
+
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal
+
+const DropdownMenuSub = DropdownMenuPrimitive.Sub
+
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
+
+const DropdownMenuSubTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+
+))
+DropdownMenuSubTrigger.displayName =
+ DropdownMenuPrimitive.SubTrigger.displayName
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuSubContent.displayName =
+ DropdownMenuPrimitive.SubContent.displayName
+
+const DropdownMenuContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+))
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
+
+const DropdownMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+ svg]:size-4 [&>svg]:shrink-0 dark:focus:bg-neutral-800 dark:focus:text-neutral-50",
+ inset && "pl-8",
+ className
+ )}
+ {...props}
+ />
+))
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+DropdownMenuCheckboxItem.displayName =
+ DropdownMenuPrimitive.CheckboxItem.displayName
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
+
+const DropdownMenuLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
+
+const DropdownMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ )
+}
+DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
+
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuGroup,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+}
diff --git a/frontend/components/ui/error-boundary.tsx b/frontend/components/ui/error-boundary.tsx
new file mode 100644
index 0000000..f3e8f3c
--- /dev/null
+++ b/frontend/components/ui/error-boundary.tsx
@@ -0,0 +1,95 @@
+'use client';
+
+import React from 'react';
+import { AlertTriangle } from 'lucide-react';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+
+interface ErrorBoundaryProps {
+ children: React.ReactNode;
+ fallback?: React.ComponentType<{ error: Error; resetError: () => void }>;
+}
+
+interface ErrorBoundaryState {
+ hasError: boolean;
+ error: Error | null;
+}
+
+class ErrorBoundary extends React.Component {
+ constructor(props: ErrorBoundaryProps) {
+ super(props);
+ this.state = { hasError: false, error: null };
+ }
+
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
+ return { hasError: true, error };
+ }
+
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
+ console.error('Error caught by boundary:', error, errorInfo);
+ }
+
+ resetError = () => {
+ this.setState({ hasError: false, error: null });
+ };
+
+ render() {
+ if (this.state.hasError) {
+ if (this.props.fallback) {
+ const FallbackComponent = this.props.fallback;
+ return ;
+ }
+
+ return (
+
+
+
+
+ Something went wrong
+
+
+ There was an error rendering this component. Please try again.
+
+
+
+ {this.state.error && (
+
+
+ {this.state.error.message}
+
+
+ )}
+
+ Try Again
+
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+// Hook version for functional components
+export function useErrorBoundary() {
+ const [error, setError] = React.useState(null);
+
+ const resetError = React.useCallback(() => {
+ setError(null);
+ }, []);
+
+ const captureError = React.useCallback((error: Error) => {
+ setError(error);
+ }, []);
+
+ React.useEffect(() => {
+ if (error) {
+ throw error;
+ }
+ }, [error]);
+
+ return { captureError, resetError };
+}
+
+export default ErrorBoundary;
\ No newline at end of file
diff --git a/frontend/components/ui/form-field.tsx b/frontend/components/ui/form-field.tsx
new file mode 100644
index 0000000..fa76fc9
--- /dev/null
+++ b/frontend/components/ui/form-field.tsx
@@ -0,0 +1,238 @@
+'use client';
+
+import { useState, useEffect, forwardRef } from 'react';
+import { Check, AlertCircle, Loader2 } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { Input } from './input';
+import { Textarea } from './textarea';
+import { Label } from './label';
+
+export interface FormFieldProps {
+ label: string;
+ name: string;
+ value: string | number;
+ onChange: (value: string) => void;
+ onBlur?: () => void;
+ type?: 'text' | 'email' | 'url' | 'number' | 'textarea';
+ placeholder?: string;
+ required?: boolean;
+ disabled?: boolean;
+ error?: string;
+ success?: string;
+ hint?: string;
+ loading?: boolean;
+ debounceMs?: number;
+ minLength?: number;
+ maxLength?: number;
+ rows?: number;
+ validator?: (value: string) => Promise | string | null;
+ className?: string;
+}
+
+export const FormField = forwardRef(
+ ({
+ label,
+ name,
+ value,
+ onChange,
+ onBlur,
+ type = 'text',
+ placeholder,
+ required = false,
+ disabled = false,
+ error,
+ success,
+ hint,
+ loading = false,
+ debounceMs = 300,
+ minLength,
+ maxLength,
+ rows = 3,
+ validator,
+ className,
+ ...props
+ }, ref) => {
+ const [internalError, setInternalError] = useState(null);
+ const [internalSuccess, setInternalSuccess] = useState(null);
+ const [isValidating, setIsValidating] = useState(false);
+ const [debounceTimer, setDebounceTimer] = useState(null);
+ const [touched, setTouched] = useState(false);
+
+ const displayError = error || internalError;
+ const displaySuccess = success || internalSuccess;
+ const showValidation = (touched || error) && !loading && !isValidating;
+
+ useEffect(() => {
+ if (!validator || !touched) return;
+
+ if (debounceTimer) {
+ clearTimeout(debounceTimer);
+ }
+
+ const timer = setTimeout(async () => {
+ if (String(value).trim() === '' && !required) {
+ setInternalError(null);
+ setInternalSuccess(null);
+ return;
+ }
+
+ setIsValidating(true);
+ setInternalError(null);
+ setInternalSuccess(null);
+
+ try {
+ const result = await validator(String(value));
+ if (result) {
+ setInternalError(result);
+ setInternalSuccess(null);
+ } else {
+ setInternalError(null);
+ setInternalSuccess('Valid');
+ }
+ } catch (err) {
+ setInternalError('Validation failed');
+ setInternalSuccess(null);
+ } finally {
+ setIsValidating(false);
+ }
+ }, debounceMs);
+
+ setDebounceTimer(timer);
+
+ return () => {
+ if (timer) clearTimeout(timer);
+ };
+ }, [value, validator, debounceMs, required, touched]);
+
+ const handleChange = (newValue: string) => {
+ onChange(newValue);
+ if (!touched) setTouched(true);
+ };
+
+ const handleBlur = () => {
+ setTouched(true);
+ onBlur?.();
+ };
+
+ const getFieldStatus = () => {
+ if (loading || isValidating) return 'loading';
+ if (showValidation && displayError) return 'error';
+ if (showValidation && displaySuccess) return 'success';
+ return 'default';
+ };
+
+ const status = getFieldStatus();
+
+ const fieldClassName = cn(
+ 'transition-all duration-200',
+ {
+ 'border-red-500 focus:border-red-500 focus:ring-red-500/20': status === 'error',
+ 'border-green-500 focus:border-green-500 focus:ring-green-500/20': status === 'success',
+ 'border-gray-300 focus:border-blue-500 focus:ring-blue-500/20': status === 'default',
+ },
+ className
+ );
+
+ const StatusIcon = () => {
+ if (status === 'loading') {
+ return ;
+ }
+ if (status === 'error') {
+ return ;
+ }
+ if (status === 'success') {
+ return ;
+ }
+ return null;
+ };
+
+ const characterCount = String(value).length;
+ const showCharacterCount = maxLength && (characterCount > maxLength * 0.8 || characterCount > maxLength);
+
+ return (
+
+
+
+ {label}
+ {required && * }
+
+ {showCharacterCount && (
+ maxLength!,
+ 'text-amber-600': characterCount > maxLength! * 0.9,
+ 'text-gray-500': characterCount <= maxLength! * 0.9,
+ })}>
+ {characterCount}/{maxLength}
+
+ )}
+
+
+
+
+ {/* Messages */}
+
+ {showValidation && displayError && (
+
+
+ {displayError}
+
+ )}
+ {showValidation && displaySuccess && !displayError && (
+
+
+ {displaySuccess}
+
+ )}
+ {hint && !showValidation && (
+
{hint}
+ )}
+
+
+ );
+ }
+);
+
+FormField.displayName = 'FormField';
\ No newline at end of file
diff --git a/frontend/components/ui/form.tsx b/frontend/components/ui/form.tsx
new file mode 100644
index 0000000..0fbc12c
--- /dev/null
+++ b/frontend/components/ui/form.tsx
@@ -0,0 +1,178 @@
+"use client"
+
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+import { Slot } from "@radix-ui/react-slot"
+import {
+ Controller,
+ FormProvider,
+ useFormContext,
+ type ControllerProps,
+ type FieldPath,
+ type FieldValues,
+} from "react-hook-form"
+
+import { cn } from "@/lib/utils"
+import { Label } from "@/components/ui/label"
+
+const Form = FormProvider
+
+type FormFieldContextValue<
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath
+> = {
+ name: TName
+}
+
+const FormFieldContext = React.createContext(
+ {} as FormFieldContextValue
+)
+
+const FormField = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath
+>({
+ ...props
+}: ControllerProps) => {
+ return (
+
+
+
+ )
+}
+
+const useFormField = () => {
+ const fieldContext = React.useContext(FormFieldContext)
+ const itemContext = React.useContext(FormItemContext)
+ const { getFieldState, formState } = useFormContext()
+
+ const fieldState = getFieldState(fieldContext.name, formState)
+
+ if (!fieldContext) {
+ throw new Error("useFormField should be used within ")
+ }
+
+ const { id } = itemContext
+
+ return {
+ id,
+ name: fieldContext.name,
+ formItemId: `${id}-form-item`,
+ formDescriptionId: `${id}-form-item-description`,
+ formMessageId: `${id}-form-item-message`,
+ ...fieldState,
+ }
+}
+
+type FormItemContextValue = {
+ id: string
+}
+
+const FormItemContext = React.createContext(
+ {} as FormItemContextValue
+)
+
+const FormItem = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const id = React.useId()
+
+ return (
+
+
+
+ )
+})
+FormItem.displayName = "FormItem"
+
+const FormLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ const { error, formItemId } = useFormField()
+
+ return (
+
+ )
+})
+FormLabel.displayName = "FormLabel"
+
+const FormControl = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ ...props }, ref) => {
+ const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
+
+ return (
+
+ )
+})
+FormControl.displayName = "FormControl"
+
+const FormDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { formDescriptionId } = useFormField()
+
+ return (
+
+ )
+})
+FormDescription.displayName = "FormDescription"
+
+const FormMessage = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, children, ...props }, ref) => {
+ const { error, formMessageId } = useFormField()
+ const body = error ? String(error?.message ?? "") : children
+
+ if (!body) {
+ return null
+ }
+
+ return (
+
+ {body}
+
+ )
+})
+FormMessage.displayName = "FormMessage"
+
+export {
+ useFormField,
+ Form,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormDescription,
+ FormMessage,
+ FormField,
+}
diff --git a/frontend/components/ui/image-upload.tsx b/frontend/components/ui/image-upload.tsx
new file mode 100644
index 0000000..439fc50
--- /dev/null
+++ b/frontend/components/ui/image-upload.tsx
@@ -0,0 +1,270 @@
+'use client';
+
+import React, { useState, useRef, useCallback } from 'react';
+import { Upload, X, Crop } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent } from '@/components/ui/card';
+import { cn } from '@/lib/utils';
+
+interface ImageUploadProps {
+ value?: string; // Current base64 or URL
+ onChange: (base64: string | null) => void;
+ maxSize?: number; // Max file size in MB
+ cropSize?: number; // Square crop size (default: 200)
+ noCrop?: boolean; // If true, resize maintaining aspect ratio instead of cropping
+ maxWidth?: number; // Max width when noCrop is true (default: 800)
+ maxHeight?: number; // Max height when noCrop is true (default: 600)
+ className?: string;
+ disabled?: boolean;
+}
+
+export function ImageUpload({
+ value,
+ onChange,
+ maxSize = 5,
+ cropSize = 200,
+ noCrop = false,
+ maxWidth = 800,
+ maxHeight = 600,
+ className,
+ disabled = false
+}: ImageUploadProps) {
+ const [isDragging, setIsDragging] = useState(false);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [previewUrl, setPreviewUrl] = useState(value || null);
+
+ const fileInputRef = useRef(null);
+
+ // Update preview when value prop changes
+ React.useEffect(() => {
+ setPreviewUrl(value || null);
+ }, [value]);
+ const canvasRef = useRef(null);
+
+ const processImage = useCallback(async (file: File) => {
+ if (!file.type.startsWith('image/')) {
+ alert('Please select an image file');
+ return;
+ }
+
+ if (file.size > maxSize * 1024 * 1024) {
+ alert(`File size must be less than ${maxSize}MB`);
+ return;
+ }
+
+ setIsProcessing(true);
+
+ try {
+ // Create image element
+ const img = new Image();
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ // Load image
+ await new Promise((resolve, reject) => {
+ img.onload = resolve;
+ img.onerror = reject;
+ img.src = URL.createObjectURL(file);
+ });
+
+ if (noCrop) {
+ // Resize maintaining aspect ratio
+ let width = img.width;
+ let height = img.height;
+
+ // Calculate scale to fit within maxWidth and maxHeight
+ const scaleX = maxWidth / width;
+ const scaleY = maxHeight / height;
+ const scale = Math.min(scaleX, scaleY, 1); // Don't upscale
+
+ width = Math.round(width * scale);
+ height = Math.round(height * scale);
+
+ // Set canvas size to actual image size
+ canvas.width = width;
+ canvas.height = height;
+
+ // Draw resized image
+ ctx.drawImage(img, 0, 0, width, height);
+ } else {
+ // Original crop behavior
+ // Set canvas size
+ canvas.width = cropSize;
+ canvas.height = cropSize;
+
+ // Calculate crop dimensions (center crop to square)
+ const size = Math.min(img.width, img.height);
+ const startX = (img.width - size) / 2;
+ const startY = (img.height - size) / 2;
+
+ // Draw cropped and resized image
+ ctx.drawImage(
+ img,
+ startX, startY, size, size, // Source rectangle (square crop)
+ 0, 0, cropSize, cropSize // Destination rectangle
+ );
+ }
+
+ // Convert to base64
+ const base64 = canvas.toDataURL('image/jpeg', 0.8);
+
+ setPreviewUrl(base64);
+ onChange(base64);
+
+ // Cleanup
+ URL.revokeObjectURL(img.src);
+ } catch (error) {
+ console.error('Error processing image:', error);
+ alert('Error processing image. Please try again.');
+ } finally {
+ setIsProcessing(false);
+ }
+ }, [cropSize, maxSize, onChange, noCrop, maxWidth, maxHeight]);
+
+ const handleFileSelect = useCallback((file: File) => {
+ processImage(file);
+ }, [processImage]);
+
+ const handleDrop = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragging(false);
+
+ if (disabled) return;
+
+ const files = Array.from(e.dataTransfer.files);
+ if (files.length > 0) {
+ handleFileSelect(files[0]);
+ }
+ }, [disabled, handleFileSelect]);
+
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ if (!disabled) {
+ setIsDragging(true);
+ }
+ }, [disabled]);
+
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragging(false);
+ }, []);
+
+ const handleFileInputChange = useCallback((e: React.ChangeEvent) => {
+ const files = e.target.files;
+ if (files && files.length > 0) {
+ handleFileSelect(files[0]);
+ }
+ // Reset input value so same file can be selected again
+ e.target.value = '';
+ }, [handleFileSelect]);
+
+ const handleRemove = useCallback(() => {
+ setPreviewUrl(null);
+ onChange(null);
+ if (fileInputRef.current) {
+ fileInputRef.current.value = '';
+ }
+ }, [onChange]);
+
+ const openFileDialog = useCallback(() => {
+ if (!disabled && fileInputRef.current) {
+ fileInputRef.current.click();
+ }
+ }, [disabled]);
+
+ return (
+
+ {/* Hidden canvas for image processing */}
+
+
+ {/* Hidden file input */}
+
+
+ {previewUrl ? (
+ /* Preview with remove option */
+
+
+
+
+ {!disabled && (
+
+
+
+ )}
+
+
+
+
+ {cropSize}×{cropSize}px
+
+
+
+
+ ) : (
+ /* Upload area */
+
+
+
+
+ {isProcessing ? (
+
+
Processing image...
+
+ Cropping to {cropSize}×{cropSize}px
+
+
+ ) : (
+
+
+ Drop an image here or click to browse
+
+
+ Will be cropped to {cropSize}×{cropSize}px • Max {maxSize}MB
+
+
+ Supports: JPG, PNG, WebP
+
+
+ )}
+
+
+ )}
+
+ );
+}
+
+export default ImageUpload;
\ No newline at end of file
diff --git a/frontend/components/ui/input.tsx b/frontend/components/ui/input.tsx
new file mode 100644
index 0000000..d9245dd
--- /dev/null
+++ b/frontend/components/ui/input.tsx
@@ -0,0 +1,22 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Input = React.forwardRef>(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Input.displayName = "Input"
+
+export { Input }
diff --git a/frontend/components/ui/label.tsx b/frontend/components/ui/label.tsx
new file mode 100644
index 0000000..5341821
--- /dev/null
+++ b/frontend/components/ui/label.tsx
@@ -0,0 +1,26 @@
+"use client"
+
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const labelVariants = cva(
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+)
+
+const Label = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+))
+Label.displayName = LabelPrimitive.Root.displayName
+
+export { Label }
diff --git a/frontend/components/ui/select.tsx b/frontend/components/ui/select.tsx
new file mode 100644
index 0000000..e7c579e
--- /dev/null
+++ b/frontend/components/ui/select.tsx
@@ -0,0 +1,158 @@
+"use client"
+
+import * as React from "react"
+import * as SelectPrimitive from "@radix-ui/react-select"
+import { cn } from "@/lib/utils"
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons"
+
+const Select = SelectPrimitive.Root
+
+const SelectGroup = SelectPrimitive.Group
+
+const SelectValue = SelectPrimitive.Value
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+ span]:line-clamp-1 dark:border-neutral-800 dark:ring-offset-neutral-950 dark:data-[placeholder]:text-neutral-400 dark:focus:ring-neutral-300",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+
+))
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+SelectScrollDownButton.displayName =
+ SelectPrimitive.ScrollDownButton.displayName
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = "popper", ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+
+
+))
+SelectContent.displayName = SelectPrimitive.Content.displayName
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SelectLabel.displayName = SelectPrimitive.Label.displayName
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+SelectItem.displayName = SelectPrimitive.Item.displayName
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+ SelectScrollUpButton,
+ SelectScrollDownButton,
+}
diff --git a/frontend/components/ui/sortable-list.tsx b/frontend/components/ui/sortable-list.tsx
new file mode 100644
index 0000000..be1452f
--- /dev/null
+++ b/frontend/components/ui/sortable-list.tsx
@@ -0,0 +1,205 @@
+'use client';
+
+import React, { useEffect, useRef, useCallback } from 'react';
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ DragEndEvent,
+} from '@dnd-kit/core';
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ verticalListSortingStrategy,
+} from '@dnd-kit/sortable';
+import {
+ useSortable,
+} from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import { GripVertical, Save, RotateCcw } from 'lucide-react';
+import { Button } from './button';
+
+interface SortableItemProps {
+ id: string;
+ children: React.ReactNode;
+ className?: string;
+}
+
+function SortableItem({ id, children, className = '' }: SortableItemProps) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ };
+
+ return (
+
+ );
+}
+
+interface SortableListProps {
+ items: T[];
+ onReorder: (items: T[]) => Promise;
+ children: (item: T, index: number) => React.ReactNode;
+ getItemId: (item: T) => string;
+ className?: string;
+}
+
+export function SortableList({
+ items,
+ onReorder,
+ children,
+ getItemId,
+ className = '',
+}: SortableListProps) {
+ const sensors = useSensors(
+ useSensor(PointerSensor),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ })
+ );
+
+ const [localItems, setLocalItems] = React.useState(items);
+ const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false);
+ const [isSaving, setIsSaving] = React.useState(false);
+ const originalItemsRef = useRef(items);
+
+ // Update local items when props change
+ useEffect(() => {
+ setLocalItems(items);
+ originalItemsRef.current = items;
+ setHasUnsavedChanges(false);
+ }, [items]);
+
+ const saveChanges = useCallback(async () => {
+ if (!hasUnsavedChanges || isSaving) return;
+
+ setIsSaving(true);
+ try {
+ await onReorder(localItems);
+ originalItemsRef.current = localItems;
+ setHasUnsavedChanges(false);
+ } catch (error) {
+ console.error('Failed to save order changes:', error);
+ // Revert to original items on error
+ setLocalItems(originalItemsRef.current);
+ setHasUnsavedChanges(false);
+ throw error; // Re-throw to let parent handle error display
+ } finally {
+ setIsSaving(false);
+ }
+ }, [hasUnsavedChanges, isSaving, onReorder, localItems]);
+
+ const discardChanges = useCallback(() => {
+ setLocalItems(originalItemsRef.current);
+ setHasUnsavedChanges(false);
+ }, []);
+
+ function handleDragEnd(event: DragEndEvent) {
+ const { active, over } = event;
+
+ if (over && active.id !== over.id) {
+ const oldIndex = localItems.findIndex(item => getItemId(item) === active.id);
+ const newIndex = localItems.findIndex(item => getItemId(item) === over.id);
+
+ const newItems = arrayMove(localItems, oldIndex, newIndex);
+ setLocalItems(newItems);
+ setHasUnsavedChanges(true);
+ }
+ }
+
+ return (
+
+ {/* Save/Discard Controls */}
+ {hasUnsavedChanges && (
+
+
+
+
+
+ You have unsaved changes to the order
+
+
+
+
+
+ Discard
+
+
+ {isSaving ? (
+ <>
+
+ Saving...
+ >
+ ) : (
+ <>
+
+ Save Order
+ >
+ )}
+
+
+
+
+ )}
+
+
+
+
+ {localItems.map((item, index) => (
+
+ {children(item, index)}
+
+ ))}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/components/ui/textarea.tsx b/frontend/components/ui/textarea.tsx
new file mode 100644
index 0000000..a065d88
--- /dev/null
+++ b/frontend/components/ui/textarea.tsx
@@ -0,0 +1,22 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Textarea = React.forwardRef<
+ HTMLTextAreaElement,
+ React.ComponentProps<"textarea">
+>(({ className, ...props }, ref) => {
+ return (
+
+ )
+})
+Textarea.displayName = "Textarea"
+
+export { Textarea }
diff --git a/frontend/components/ui/toast.tsx b/frontend/components/ui/toast.tsx
new file mode 100644
index 0000000..5acc6f7
--- /dev/null
+++ b/frontend/components/ui/toast.tsx
@@ -0,0 +1,128 @@
+"use client"
+
+import * as React from "react"
+import * as ToastPrimitives from "@radix-ui/react-toast"
+import { cva, type VariantProps } from "class-variance-authority"
+import { cn } from "@/lib/utils"
+import { Cross2Icon } from "@radix-ui/react-icons"
+
+const ToastProvider = ToastPrimitives.Provider
+
+const ToastViewport = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastViewport.displayName = ToastPrimitives.Viewport.displayName
+
+const toastVariants = cva(
+ "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border border-neutral-200 p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full dark:border-neutral-800",
+ {
+ variants: {
+ variant: {
+ default: "border bg-white text-neutral-950 dark:bg-neutral-950 dark:text-neutral-50",
+ destructive:
+ "destructive group border-red-500 bg-red-500 text-neutral-50 dark:border-red-900 dark:bg-red-900 dark:text-neutral-50",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+const Toast = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, variant, ...props }, ref) => {
+ return (
+
+ )
+})
+Toast.displayName = ToastPrimitives.Root.displayName
+
+const ToastAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastAction.displayName = ToastPrimitives.Action.displayName
+
+const ToastClose = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+ToastClose.displayName = ToastPrimitives.Close.displayName
+
+const ToastTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastTitle.displayName = ToastPrimitives.Title.displayName
+
+const ToastDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+ToastDescription.displayName = ToastPrimitives.Description.displayName
+
+type ToastProps = React.ComponentPropsWithoutRef
+
+type ToastActionElement = React.ReactElement
+
+export {
+ type ToastProps,
+ type ToastActionElement,
+ ToastProvider,
+ ToastViewport,
+ Toast,
+ ToastTitle,
+ ToastDescription,
+ ToastClose,
+ ToastAction,
+}
diff --git a/frontend/components/ui/toaster.tsx b/frontend/components/ui/toaster.tsx
new file mode 100644
index 0000000..f6adb95
--- /dev/null
+++ b/frontend/components/ui/toaster.tsx
@@ -0,0 +1,35 @@
+"use client"
+
+import {
+ Toast,
+ ToastClose,
+ ToastDescription,
+ ToastProvider,
+ ToastTitle,
+ ToastViewport,
+} from "@/components/ui/toast"
+import { useToast } from "@/hooks/use-toast"
+
+export function Toaster() {
+ const { toasts } = useToast()
+
+ return (
+
+ {toasts.map(function ({ id, title, description, action, ...props }) {
+ return (
+
+
+ {title && {title} }
+ {description && (
+ {description}
+ )}
+
+ {action}
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/frontend/data/.gitkeep b/frontend/data/.gitkeep
new file mode 100644
index 0000000..772563a
--- /dev/null
+++ b/frontend/data/.gitkeep
@@ -0,0 +1 @@
+# placeholder to keep directory in git
\ No newline at end of file
diff --git a/frontend/data/board_members.json b/frontend/data/board_members.json
new file mode 100644
index 0000000..76f9758
--- /dev/null
+++ b/frontend/data/board_members.json
@@ -0,0 +1,74 @@
+[
+ {
+ "id": 1,
+ "name": "Sarah Chen",
+ "role": "President",
+ "photoUrl": "https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=400&h=400&fit=crop&crop=face",
+ "bio": "Third-year Computer Science major with a passion for machine learning and quantum computing. Previously interned at Google Research and published papers on neural network optimization.",
+ "socials": "{\"email\":\"sarah.chen@university.edu\",\"linkedin\":\"https://linkedin.com/in/sarah-chen-cs\",\"github\":\"https://github.com/sarahchen\",\"website\":\"https://sarahchen.dev\"}",
+ "displayOrder": 1,
+ "active": true,
+ "createdAt": "2025-09-24T11:10:41.912Z",
+ "updatedAt": "2025-09-24T11:10:41.912Z"
+ },
+ {
+ "id": 2,
+ "name": "Marcus Rodriguez",
+ "role": "Vice President",
+ "photoUrl": "https://images.unsplash.com/photo-1560250097-0b93528c311a?w=400&h=400&fit=crop&crop=face",
+ "bio": "Physics and Mathematics double major interested in theoretical physics and computational modeling. Leads our quantum computing initiatives and has experience with high-performance computing.",
+ "socials": "{\"email\":\"marcus.rodriguez@university.edu\",\"linkedin\":\"https://linkedin.com/in/marcus-rodriguez-physics\",\"github\":\"https://github.com/marcusrodriguez\"}",
+ "displayOrder": 2,
+ "active": true,
+ "createdAt": "2025-09-24T11:10:41.912Z",
+ "updatedAt": "2025-09-24T11:10:41.912Z"
+ },
+ {
+ "id": 3,
+ "name": "Emily Johnson",
+ "role": "Secretary",
+ "photoUrl": "https://images.unsplash.com/photo-1580489944761-15a19d654956?w=400&h=400&fit=crop&crop=face",
+ "bio": "Data Science major with expertise in statistical analysis and data visualization. Passionate about using data to solve environmental and social issues. Organizes our workshops and events.",
+ "socials": "{\"email\":\"emily.johnson@university.edu\",\"linkedin\":\"https://linkedin.com/in/emily-johnson-datasci\",\"twitter\":\"https://twitter.com/emily_dataviz\"}",
+ "displayOrder": 3,
+ "active": true,
+ "createdAt": "2025-09-24T11:10:41.913Z",
+ "updatedAt": "2025-09-24T11:10:41.913Z"
+ },
+ {
+ "id": 4,
+ "name": "David Kim",
+ "role": "Treasurer",
+ "photoUrl": "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=400&h=400&fit=crop&crop=face",
+ "bio": "Economics and Computer Science major with interests in algorithmic trading and financial technology. Manages club finances and coordinates funding for research projects.",
+ "socials": "{\"email\":\"david.kim@university.edu\",\"linkedin\":\"https://linkedin.com/in/david-kim-fintech\",\"github\":\"https://github.com/davidkim\"}",
+ "displayOrder": 4,
+ "active": true,
+ "createdAt": "2025-09-24T11:10:41.913Z",
+ "updatedAt": "2025-09-24T11:10:41.913Z"
+ },
+ {
+ "id": 5,
+ "name": "Aisha Patel",
+ "role": "Research Coordinator",
+ "photoUrl": "",
+ "bio": "Bioengineering major focused on computational biology and medical AI. Coordinates research collaborations with faculty and organizes our bioinformatics study groups.",
+ "socials": "{\"email\":\"aisha.patel@university.edu\",\"linkedin\":\"https://linkedin.com/in/aisha-patel-bioeng\",\"github\":\"https://github.com/aishapatel\",\"website\":\"https://aishapatel.bio\"}",
+ "displayOrder": 5,
+ "active": true,
+ "createdAt": "2025-09-24T11:10:41.913Z",
+ "updatedAt": "2025-09-24T11:10:41.913Z"
+ },
+ {
+ "id": 6,
+ "name": "James Thompson",
+ "role": "Workshop Coordinator",
+ "photoUrl": "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=400&fit=crop&crop=face",
+ "bio": "Computer Engineering major with expertise in embedded systems and IoT. Organizes hands-on technical workshops and maintains our lab equipment.",
+ "socials": "{\"email\":\"james.thompson@university.edu\",\"linkedin\":\"https://linkedin.com/in/james-thompson-eng\",\"github\":\"https://github.com/jamesthompson\"}",
+ "displayOrder": 6,
+ "active": true,
+ "createdAt": "2025-09-24T11:10:41.913Z",
+ "updatedAt": "2025-09-24T11:10:41.913Z"
+ }
+]
\ No newline at end of file
diff --git a/frontend/data/projects.json b/frontend/data/projects.json
new file mode 100644
index 0000000..432813f
--- /dev/null
+++ b/frontend/data/projects.json
@@ -0,0 +1,54 @@
+[
+ {
+ "id": 1,
+ "title": "Quantum Computing Workshop Series",
+ "slug": "quantum-computing-workshop",
+ "description": "A comprehensive workshop series introducing students to quantum computing principles, quantum algorithms, and hands-on programming with Qiskit. Covers quantum gates, superposition, entanglement, and basic quantum algorithms like Deutsch-Jozsa and Grover's algorithm.",
+ "imageUrl": "",
+ "tags": "quantum computing,workshops,education,programming",
+ "status": "active",
+ "links": "{\"website\":\"https://quantumworkshop.example.com\",\"github\":\"https://github.com/gradientscience/quantum-workshop\",\"materials\":\"https://drive.google.com/quantum-materials\"}",
+ "displayOrder": 1,
+ "createdAt": "2025-09-24T11:10:41.910Z",
+ "updatedAt": "2025-09-24T11:10:41.910Z"
+ },
+ {
+ "id": 2,
+ "title": "AI Ethics Research Project",
+ "slug": "ai-ethics-research",
+ "description": "Investigating the ethical implications of artificial intelligence in decision-making systems. Our team is conducting surveys, analyzing bias in ML models, and developing frameworks for responsible AI development in academic and industry settings.",
+ "imageUrl": "https://images.unsplash.com/photo-1485827404703-89b55fcc595e?w=800&h=600&fit=crop",
+ "tags": "artificial intelligence,ethics,research,bias",
+ "status": "active",
+ "links": "{\"paper\":\"https://arxiv.org/abs/2023.ai-ethics\",\"github\":\"https://github.com/gradientscience/ai-ethics\",\"survey\":\"https://forms.gle/ai-ethics-survey\"}",
+ "displayOrder": 2,
+ "createdAt": "2025-09-24T11:10:41.911Z",
+ "updatedAt": "2025-09-24T11:10:41.911Z"
+ },
+ {
+ "id": 3,
+ "title": "Climate Data Visualization Platform",
+ "slug": "climate-data-viz",
+ "description": "Building an interactive web platform to visualize climate change data from various sources. The platform will help students and researchers understand climate trends, temperature changes, and environmental impacts through dynamic charts and maps.",
+ "imageUrl": "https://images.unsplash.com/photo-1581833971358-2c8b550f87b3?w=800&h=600&fit=crop",
+ "tags": "climate change,data visualization,web development,environmental science",
+ "status": "planned",
+ "links": "{\"demo\":\"https://climate-viz.example.com\",\"github\":\"https://github.com/gradientscience/climate-viz\"}",
+ "displayOrder": 3,
+ "createdAt": "2025-09-24T11:10:41.911Z",
+ "updatedAt": "2025-09-24T11:10:41.911Z"
+ },
+ {
+ "id": 4,
+ "title": "Bioinformatics Study Group",
+ "slug": "bioinformatics-study-group",
+ "description": "Weekly study sessions focused on computational biology and bioinformatics. We cover topics like sequence analysis, phylogenetics, structural biology, and genomics using tools like BLAST, Clustal, and Python BioPython library.",
+ "imageUrl": "https://images.unsplash.com/photo-1576086213369-97a306d36557?w=800&h=600&fit=crop",
+ "tags": "bioinformatics,computational biology,study group,genomics",
+ "status": "completed",
+ "links": "{\"resources\":\"https://bio-study.example.com\",\"github\":\"https://github.com/gradientscience/bioinformatics\"}",
+ "displayOrder": 4,
+ "createdAt": "2025-09-24T11:10:41.911Z",
+ "updatedAt": "2025-09-24T11:10:41.911Z"
+ }
+]
\ No newline at end of file
diff --git a/frontend/data/seed.ts b/frontend/data/seed.ts
new file mode 100644
index 0000000..5aed777
--- /dev/null
+++ b/frontend/data/seed.ts
@@ -0,0 +1,258 @@
+import { config } from 'dotenv';
+import path from 'path';
+
+// Load environment variables
+config({ path: path.join(process.cwd(), '.env') });
+
+import dbConnect from '../lib/mongodb';
+import Project from '../lib/models/Project';
+import BoardMember from '../lib/models/BoardMember';
+import { adminAuthRepo } from '../lib/repositories/adminAuth';
+import type { ProjectInput, BoardMemberInput } from '../lib/types';
+
+// Sample projects data
+const sampleProjects: ProjectInput[] = [
+ {
+ title: "Quantum Computing Workshop Series",
+ slug: "quantum-computing-workshop",
+ description: "A comprehensive workshop series introducing students to quantum computing principles, quantum algorithms, and hands-on programming with Qiskit. Covers quantum gates, superposition, entanglement, and basic quantum algorithms like Deutsch-Jozsa and Grover's algorithm.",
+ imageUrl: "https://images.unsplash.com/photo-1518709268805-4e9042af2176?w=800&h=600&fit=crop",
+ tags: "quantum computing,workshops,education,programming",
+ status: "active",
+ links: JSON.stringify({
+ website: "https://quantumworkshop.example.com",
+ github: "https://github.com/gradientscience/quantum-workshop",
+ materials: "https://drive.google.com/quantum-materials"
+ }),
+ displayOrder: 1
+ },
+ {
+ title: "AI Ethics Research Project",
+ slug: "ai-ethics-research",
+ description: "Investigating the ethical implications of artificial intelligence in decision-making systems. Our team is conducting surveys, analyzing bias in ML models, and developing frameworks for responsible AI development in academic and industry settings.",
+ imageUrl: "https://images.unsplash.com/photo-1485827404703-89b55fcc595e?w=800&h=600&fit=crop",
+ tags: "artificial intelligence,ethics,research,bias",
+ status: "active",
+ links: JSON.stringify({
+ paper: "https://arxiv.org/abs/2023.ai-ethics",
+ github: "https://github.com/gradientscience/ai-ethics",
+ survey: "https://forms.gle/ai-ethics-survey"
+ }),
+ displayOrder: 2
+ },
+ {
+ title: "Climate Data Visualization Platform",
+ slug: "climate-data-viz",
+ description: "Building an interactive web platform to visualize climate change data from various sources. The platform will help students and researchers understand climate trends, temperature changes, and environmental impacts through dynamic charts and maps.",
+ imageUrl: "https://images.unsplash.com/photo-1581833971358-2c8b550f87b3?w=800&h=600&fit=crop",
+ tags: "climate change,data visualization,web development,environmental science",
+ status: "planned",
+ links: JSON.stringify({
+ demo: "https://climate-viz.example.com",
+ github: "https://github.com/gradientscience/climate-viz"
+ }),
+ displayOrder: 3
+ },
+ {
+ title: "Bioinformatics Study Group",
+ slug: "bioinformatics-study-group",
+ description: "Weekly study sessions focused on computational biology and bioinformatics. We cover topics like sequence analysis, phylogenetics, structural biology, and genomics using tools like BLAST, Clustal, and Python BioPython library.",
+ imageUrl: "https://images.unsplash.com/photo-1576086213369-97a306d36557?w=800&h=600&fit=crop",
+ tags: "bioinformatics,computational biology,study group,genomics",
+ status: "completed",
+ links: JSON.stringify({
+ resources: "https://bio-study.example.com",
+ github: "https://github.com/gradientscience/bioinformatics"
+ }),
+ displayOrder: 4
+ }
+];
+
+// Sample board members data with working profile images
+const sampleBoardMembers: BoardMemberInput[] = [
+ {
+ name: "Sarah Chen",
+ role: "President",
+ roleType: "board_member",
+ photoUrl: "https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=400&h=400&fit=crop&crop=face",
+ bio: "Third-year Computer Science major with a passion for machine learning and quantum computing. Previously interned at Google Research and published papers on neural network optimization.",
+ socials: JSON.stringify({
+ email: "sarah.chen@university.edu",
+ linkedin: "https://linkedin.com/in/sarah-chen-cs",
+ github: "https://github.com/sarahchen",
+ website: "https://sarahchen.dev"
+ }),
+ displayOrder: 1,
+ active: true
+ },
+ {
+ name: "Marcus Rodriguez",
+ role: "Vice President",
+ roleType: "board_member",
+ photoUrl: "https://images.unsplash.com/photo-1560250097-0b93528c311a?w=400&h=400&fit=crop&crop=face",
+ bio: "Physics and Mathematics double major interested in theoretical physics and computational modeling. Leads our quantum computing initiatives and has experience with high-performance computing.",
+ socials: JSON.stringify({
+ email: "marcus.rodriguez@university.edu",
+ linkedin: "https://linkedin.com/in/marcus-rodriguez-physics",
+ github: "https://github.com/marcusrodriguez"
+ }),
+ displayOrder: 2,
+ active: true
+ },
+ {
+ name: "Emily Johnson",
+ role: "Secretary",
+ roleType: "board_member",
+ photoUrl: "https://images.unsplash.com/photo-1580489944761-15a19d654956?w=400&h=400&fit=crop&crop=face",
+ bio: "Data Science major with expertise in statistical analysis and data visualization. Passionate about using data to solve environmental and social issues. Organizes our workshops and events.",
+ socials: JSON.stringify({
+ email: "emily.johnson@university.edu",
+ linkedin: "https://linkedin.com/in/emily-johnson-datasci",
+ twitter: "https://twitter.com/emily_dataviz"
+ }),
+ displayOrder: 3,
+ active: true
+ },
+ {
+ name: "David Kim",
+ role: "Treasurer",
+ roleType: "board_member",
+ photoUrl: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=400&h=400&fit=crop&crop=face",
+ bio: "Economics and Computer Science major with interests in algorithmic trading and financial technology. Manages club finances and coordinates funding for research projects.",
+ socials: JSON.stringify({
+ email: "david.kim@university.edu",
+ linkedin: "https://linkedin.com/in/david-kim-fintech",
+ github: "https://github.com/davidkim"
+ }),
+ displayOrder: 4,
+ active: true
+ },
+ {
+ name: "Aisha Patel",
+ role: "Research Coordinator",
+ roleType: "coordinator",
+ photoUrl: "https://images.unsplash.com/photo-1594736797933-d0c29e4b0c40?w=400&h=400&fit=crop&crop=face",
+ bio: "Bioengineering major focused on computational biology and medical AI. Coordinates research collaborations with faculty and organizes our bioinformatics study groups.",
+ socials: JSON.stringify({
+ email: "aisha.patel@university.edu",
+ linkedin: "https://linkedin.com/in/aisha-patel-bioeng",
+ github: "https://github.com/aishapatel",
+ website: "https://aishapatel.bio"
+ }),
+ displayOrder: 5,
+ active: true
+ },
+ {
+ name: "James Thompson",
+ role: "Workshop Coordinator",
+ roleType: "coordinator",
+ photoUrl: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=400&fit=crop&crop=face",
+ bio: "Computer Engineering major with expertise in embedded systems and IoT. Organizes hands-on technical workshops and maintains our lab equipment.",
+ socials: JSON.stringify({
+ email: "james.thompson@university.edu",
+ linkedin: "https://linkedin.com/in/james-thompson-eng",
+ github: "https://github.com/jamesthompson"
+ }),
+ displayOrder: 6,
+ active: true
+ }
+];
+
+export async function seedDatabase(): Promise {
+ console.log('🚀 Starting MongoDB database seeding...');
+
+ try {
+ // Connect to MongoDB
+ await dbConnect();
+ console.log('✅ Connected to MongoDB');
+
+ // Clear existing data (optional - remove this if you want to append)
+ await Project.deleteMany({});
+ await BoardMember.deleteMany({});
+ console.log('🗑️ Cleared existing data');
+
+ // Seed projects
+ console.log('📝 Seeding projects...');
+ for (const projectData of sampleProjects) {
+ const project = new Project({
+ title: projectData.title,
+ slug: projectData.slug,
+ description: projectData.description,
+ imageUrl: projectData.imageUrl || '',
+ tags: projectData.tags || '',
+ status: projectData.status || 'planned',
+ links: projectData.links || '',
+ displayOrder: projectData.displayOrder || 0,
+ });
+
+ await project.save();
+ console.log(` ✅ Created project: ${project.title}`);
+ }
+
+ // Seed board members
+ console.log('👥 Seeding board members...');
+ for (const memberData of sampleBoardMembers) {
+ const member = new BoardMember({
+ name: memberData.name,
+ role: memberData.role,
+ photoUrl: memberData.photoUrl || '',
+ bio: memberData.bio || '',
+ socials: memberData.socials || '',
+ displayOrder: memberData.displayOrder || 0,
+ active: memberData.active !== undefined ? memberData.active : true,
+ });
+
+ await member.save();
+ console.log(` ✅ Created board member: ${member.name} (${member.role})`);
+ }
+
+ console.log('🎉 Database seeding completed successfully!');
+
+ // Create admin user if none exists
+ console.log('👤 Setting up admin user...');
+ const hasAdmin = await adminAuthRepo.hasAdminUsers();
+
+ if (!hasAdmin) {
+ // Create default admin user (you should change this password!)
+ const adminCreated = await adminAuthRepo.createAdminUser(
+ 'admin',
+ 'changeme123', // Default password - CHANGE THIS!
+ 'admin@gradientscience.club'
+ );
+
+ if (adminCreated) {
+ console.log(' ✅ Default admin user created:');
+ console.log(' 📧 Username: admin');
+ console.log(' 🔐 Password: changeme123');
+ console.log(' ⚠️ IMPORTANT: Change this password immediately!');
+ }
+ } else {
+ console.log(' ✅ Admin user already exists');
+ }
+
+ // Verify the seeding
+ const projectCount = await Project.countDocuments();
+ const memberCount = await BoardMember.countDocuments();
+ console.log(`📊 Total: ${projectCount} projects and ${memberCount} board members`);
+
+ } catch (error) {
+ console.error('❌ Error during database seeding:', error);
+ throw error;
+ } finally {
+ // Note: We don't close the connection here as it might be used by other operations
+ console.log('✨ Seeding process completed');
+ }
+}
+
+// Run seeding if this file is executed directly
+if (require.main === module) {
+ seedDatabase()
+ .then(() => {
+ console.log('✅ Seeding finished successfully');
+ process.exit(0);
+ })
+ .catch((error) => {
+ console.error('❌ Seeding failed:', error);
+ process.exit(1);
+ });
+}
\ No newline at end of file
diff --git a/frontend/env.example b/frontend/env.example
new file mode 100644
index 0000000..117438b
--- /dev/null
+++ b/frontend/env.example
@@ -0,0 +1,3 @@
+# MongoDB Atlas connection string
+# Get this from your MongoDB Atlas dashboard
+MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/gradient-science-club?retryWrites=true&w=majority
\ No newline at end of file
diff --git a/frontend/hooks/use-autosave.ts b/frontend/hooks/use-autosave.ts
new file mode 100644
index 0000000..69723fa
--- /dev/null
+++ b/frontend/hooks/use-autosave.ts
@@ -0,0 +1,144 @@
+'use client';
+
+import { useEffect, useRef, useCallback } from 'react';
+import { useToast } from './use-toast';
+import { createAutoSave, getAutoSavedData, clearAutoSavedData, type AutoSaveOptions } from '@/lib/validation';
+
+interface UseAutoSaveOptions {
+ key: string;
+ data: any;
+ delay?: number;
+ enabled?: boolean;
+ onSave?: (data: any) => void;
+ onRestore?: (data: any) => void;
+ showToasts?: boolean;
+}
+
+export const useAutoSave = (options: UseAutoSaveOptions) => {
+ const { toast } = useToast();
+ const {
+ key,
+ data,
+ delay = 5000, // 5 seconds
+ enabled = true,
+ onSave,
+ onRestore,
+ showToasts = true,
+ } = options;
+
+ const autoSaveRef = useRef<((data: any) => void) | null>(null);
+ const lastSaveRef = useRef('');
+
+ // Initialize auto-save function
+ useEffect(() => {
+ if (!enabled) return;
+
+ autoSaveRef.current = createAutoSave({
+ key,
+ delay,
+ onSave: (savedData) => {
+ if (showToasts) {
+ toast({
+ title: 'Draft saved',
+ description: 'Your changes have been saved automatically.',
+ duration: 2000,
+ });
+ }
+ onSave?.(savedData);
+ },
+ onError: (error) => {
+ console.error('Auto-save failed:', error);
+ if (showToasts) {
+ toast({
+ title: 'Auto-save failed',
+ description: 'Could not save your draft. Please save manually.',
+ variant: 'destructive',
+ duration: 4000,
+ });
+ }
+ },
+ });
+ }, [key, delay, enabled, showToasts, onSave, toast]);
+
+ // Auto-save when data changes
+ useEffect(() => {
+ if (!enabled || !autoSaveRef.current) return;
+
+ const currentDataString = JSON.stringify(data);
+
+ // Only save if data has actually changed
+ if (currentDataString !== lastSaveRef.current && Object.keys(data).length > 0) {
+ // Check if the data has meaningful content (not just empty strings)
+ const hasContent = Object.values(data).some(value =>
+ value !== null && value !== undefined && String(value).trim() !== ''
+ );
+
+ if (hasContent) {
+ autoSaveRef.current(data);
+ lastSaveRef.current = currentDataString;
+ }
+ }
+ }, [data, enabled]);
+
+ // Restore saved data
+ const restoreSavedData = useCallback((maxAge?: number): any | null => {
+ try {
+ const savedData = getAutoSavedData(key, maxAge);
+ if (savedData) {
+ onRestore?.(savedData);
+ if (showToasts) {
+ toast({
+ title: 'Draft restored',
+ description: 'Your previous draft has been restored.',
+ duration: 3000,
+ });
+ }
+ }
+ return savedData;
+ } catch (error) {
+ console.error('Failed to restore auto-saved data:', error);
+ if (showToasts) {
+ toast({
+ title: 'Restore failed',
+ description: 'Could not restore your previous draft.',
+ variant: 'destructive',
+ duration: 4000,
+ });
+ }
+ return null;
+ }
+ }, [key, onRestore, showToasts, toast]);
+
+ // Clear saved data
+ const clearSavedData = useCallback(() => {
+ try {
+ clearAutoSavedData(key);
+ lastSaveRef.current = '';
+ if (showToasts) {
+ toast({
+ title: 'Draft cleared',
+ description: 'Auto-saved draft has been cleared.',
+ duration: 2000,
+ });
+ }
+ } catch (error) {
+ console.error('Failed to clear auto-saved data:', error);
+ }
+ }, [key, showToasts, toast]);
+
+ // Check if there is saved data
+ const hasSavedData = useCallback((maxAge?: number): boolean => {
+ try {
+ const savedData = getAutoSavedData(key, maxAge);
+ return savedData !== null;
+ } catch {
+ return false;
+ }
+ }, [key]);
+
+ return {
+ restoreSavedData,
+ clearSavedData,
+ hasSavedData,
+ };
+};
\ No newline at end of file
diff --git a/frontend/hooks/use-toast.ts b/frontend/hooks/use-toast.ts
new file mode 100644
index 0000000..02e111d
--- /dev/null
+++ b/frontend/hooks/use-toast.ts
@@ -0,0 +1,194 @@
+"use client"
+
+// Inspired by react-hot-toast library
+import * as React from "react"
+
+import type {
+ ToastActionElement,
+ ToastProps,
+} from "@/components/ui/toast"
+
+const TOAST_LIMIT = 1
+const TOAST_REMOVE_DELAY = 1000000
+
+type ToasterToast = ToastProps & {
+ id: string
+ title?: React.ReactNode
+ description?: React.ReactNode
+ action?: ToastActionElement
+}
+
+const actionTypes = {
+ ADD_TOAST: "ADD_TOAST",
+ UPDATE_TOAST: "UPDATE_TOAST",
+ DISMISS_TOAST: "DISMISS_TOAST",
+ REMOVE_TOAST: "REMOVE_TOAST",
+} as const
+
+let count = 0
+
+function genId() {
+ count = (count + 1) % Number.MAX_SAFE_INTEGER
+ return count.toString()
+}
+
+type ActionType = typeof actionTypes
+
+type Action =
+ | {
+ type: ActionType["ADD_TOAST"]
+ toast: ToasterToast
+ }
+ | {
+ type: ActionType["UPDATE_TOAST"]
+ toast: Partial
+ }
+ | {
+ type: ActionType["DISMISS_TOAST"]
+ toastId?: ToasterToast["id"]
+ }
+ | {
+ type: ActionType["REMOVE_TOAST"]
+ toastId?: ToasterToast["id"]
+ }
+
+interface State {
+ toasts: ToasterToast[]
+}
+
+const toastTimeouts = new Map>()
+
+const addToRemoveQueue = (toastId: string) => {
+ if (toastTimeouts.has(toastId)) {
+ return
+ }
+
+ const timeout = setTimeout(() => {
+ toastTimeouts.delete(toastId)
+ dispatch({
+ type: "REMOVE_TOAST",
+ toastId: toastId,
+ })
+ }, TOAST_REMOVE_DELAY)
+
+ toastTimeouts.set(toastId, timeout)
+}
+
+export const reducer = (state: State, action: Action): State => {
+ switch (action.type) {
+ case "ADD_TOAST":
+ return {
+ ...state,
+ toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
+ }
+
+ case "UPDATE_TOAST":
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === action.toast.id ? { ...t, ...action.toast } : t
+ ),
+ }
+
+ case "DISMISS_TOAST": {
+ const { toastId } = action
+
+ // ! Side effects ! - This could be extracted into a dismissToast() action,
+ // but I'll keep it here for simplicity
+ if (toastId) {
+ addToRemoveQueue(toastId)
+ } else {
+ state.toasts.forEach((toast) => {
+ addToRemoveQueue(toast.id)
+ })
+ }
+
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === toastId || toastId === undefined
+ ? {
+ ...t,
+ open: false,
+ }
+ : t
+ ),
+ }
+ }
+ case "REMOVE_TOAST":
+ if (action.toastId === undefined) {
+ return {
+ ...state,
+ toasts: [],
+ }
+ }
+ return {
+ ...state,
+ toasts: state.toasts.filter((t) => t.id !== action.toastId),
+ }
+ }
+}
+
+const listeners: Array<(state: State) => void> = []
+
+let memoryState: State = { toasts: [] }
+
+function dispatch(action: Action) {
+ memoryState = reducer(memoryState, action)
+ listeners.forEach((listener) => {
+ listener(memoryState)
+ })
+}
+
+type Toast = Omit
+
+function toast({ ...props }: Toast) {
+ const id = genId()
+
+ const update = (props: ToasterToast) =>
+ dispatch({
+ type: "UPDATE_TOAST",
+ toast: { ...props, id },
+ })
+ const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
+
+ dispatch({
+ type: "ADD_TOAST",
+ toast: {
+ ...props,
+ id,
+ open: true,
+ onOpenChange: (open) => {
+ if (!open) dismiss()
+ },
+ },
+ })
+
+ return {
+ id: id,
+ dismiss,
+ update,
+ }
+}
+
+function useToast() {
+ const [state, setState] = React.useState(memoryState)
+
+ React.useEffect(() => {
+ listeners.push(setState)
+ return () => {
+ const index = listeners.indexOf(setState)
+ if (index > -1) {
+ listeners.splice(index, 1)
+ }
+ }
+ }, [state])
+
+ return {
+ ...state,
+ toast,
+ dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
+ }
+}
+
+export { useToast, toast }
diff --git a/frontend/lib/auth.ts b/frontend/lib/auth.ts
new file mode 100644
index 0000000..280fb91
--- /dev/null
+++ b/frontend/lib/auth.ts
@@ -0,0 +1,45 @@
+import { NextRequest } from 'next/server';
+import { adminAuthRepo } from './repositories/adminAuth';
+
+export interface AuthResult {
+ isAuthenticated: boolean;
+ user?: string;
+ error?: string;
+}
+
+/**
+ * Validates admin authentication from request headers
+ * This is used in admin route handlers after middleware passes credentials
+ */
+export async function validateAdminAuth(request: NextRequest): Promise {
+ try {
+ // Get credentials from headers (set by middleware)
+ const username = request.headers.get('x-auth-user');
+ const password = request.headers.get('x-auth-pass');
+
+ if (!username || !password) {
+ return { isAuthenticated: false, error: 'No credentials provided' };
+ }
+
+ // Validate against database
+ const isValid = await adminAuthRepo.authenticate(username, password);
+
+ if (isValid) {
+ return { isAuthenticated: true, user: username };
+ } else {
+ return { isAuthenticated: false, error: 'Invalid credentials' };
+ }
+ } catch (error) {
+ console.error('Auth validation error:', error);
+ return { isAuthenticated: false, error: 'Authentication error' };
+ }
+}
+
+/**
+ * Middleware wrapper for admin route protection
+ * Usage: const auth = await requireAdminAuth(request);
+ * if (!auth.isAuthenticated) return NextResponse.json({ error: auth.error }, { status: 401 });
+ */
+export async function requireAdminAuth(request: NextRequest): Promise {
+ return await validateAdminAuth(request);
+}
\ No newline at end of file
diff --git a/frontend/lib/db.ts b/frontend/lib/db.ts
new file mode 100644
index 0000000..b9a67b5
--- /dev/null
+++ b/frontend/lib/db.ts
@@ -0,0 +1,7 @@
+// This file is deprecated - MongoDB is now used for data storage
+// All database operations are handled through MongoDB models and repositories
+// See: /lib/mongodb.ts for database connection
+// See: /lib/models/ for data models
+// See: /lib/repositories/ for data access layer
+
+export const DEPRECATED_MESSAGE = 'This JSON-based storage system has been replaced by MongoDB. Use the repositories in /lib/repositories/ instead.';
\ No newline at end of file
diff --git a/frontend/lib/models/AdminUser.ts b/frontend/lib/models/AdminUser.ts
new file mode 100644
index 0000000..35393e9
--- /dev/null
+++ b/frontend/lib/models/AdminUser.ts
@@ -0,0 +1,67 @@
+import mongoose, { Schema, Document } from 'mongoose';
+import bcrypt from 'bcryptjs';
+
+export interface IAdminUser extends Document {
+ username: string;
+ password: string;
+ email?: string;
+ isActive: boolean;
+ lastLogin?: Date;
+ createdAt: Date;
+ updatedAt: Date;
+ comparePassword(candidatePassword: string): Promise;
+}
+
+const AdminUserSchema = new Schema({
+ username: {
+ type: String,
+ required: true,
+ unique: true,
+ trim: true,
+ lowercase: true,
+ },
+ password: {
+ type: String,
+ required: true,
+ minlength: 6,
+ },
+ email: {
+ type: String,
+ trim: true,
+ lowercase: true,
+ },
+ isActive: {
+ type: Boolean,
+ default: true,
+ },
+ lastLogin: {
+ type: Date,
+ },
+}, {
+ timestamps: true,
+});
+
+// Hash password before saving
+AdminUserSchema.pre('save', async function(next) {
+ if (!this.isModified('password')) return next();
+
+ try {
+ const salt = await bcrypt.genSalt(12);
+ this.password = await bcrypt.hash(this.password, salt);
+ next();
+ } catch (error) {
+ next(error as Error);
+ }
+});
+
+// Compare password method
+AdminUserSchema.methods.comparePassword = async function(candidatePassword: string): Promise {
+ return bcrypt.compare(candidatePassword, this.password);
+};
+
+// Create indexes
+AdminUserSchema.index({ username: 1 });
+AdminUserSchema.index({ isActive: 1 });
+
+// Prevent re-compilation during development
+export default mongoose.models.AdminUser || mongoose.model('AdminUser', AdminUserSchema);
\ No newline at end of file
diff --git a/frontend/lib/models/BoardMember.ts b/frontend/lib/models/BoardMember.ts
new file mode 100644
index 0000000..e2abe21
--- /dev/null
+++ b/frontend/lib/models/BoardMember.ts
@@ -0,0 +1,78 @@
+import mongoose, { Schema, Document } from 'mongoose';
+
+export interface IBoardMember extends Document {
+ name: string;
+ role?: string; // Optional role
+ roleType: 'board_member' | 'coordinator' | 'member'; // Flag to distinguish between board members, coordinators, and regular members
+ photoUrl?: string;
+ photoBase64?: string; // Base64 encoded image (takes priority over photoUrl)
+ bio?: string;
+ socials?: string; // JSON string
+ displayOrder: number;
+ active: boolean;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+const BoardMemberSchema = new Schema({
+ name: {
+ type: String,
+ required: true,
+ trim: true,
+ },
+ role: {
+ type: String,
+ required: false, // Made optional
+ trim: true,
+ validate: {
+ validator: function(v: string) {
+ // Allow empty strings, null, or undefined for members
+ if (!v || v.trim() === '') return true;
+ // For non-empty strings, must be at least 2 characters
+ return v.trim().length >= 2;
+ },
+ message: 'Role must be at least 2 characters long if provided'
+ }
+ },
+ roleType: {
+ type: String,
+ enum: ['board_member', 'coordinator', 'member'],
+ required: true,
+ default: 'member',
+ },
+ photoUrl: {
+ type: String,
+ trim: true,
+ },
+ photoBase64: {
+ type: String,
+ trim: true,
+ },
+ bio: {
+ type: String,
+ trim: true,
+ },
+ socials: {
+ type: String, // JSON string
+ trim: true,
+ },
+ displayOrder: {
+ type: Number,
+ default: 0,
+ },
+ active: {
+ type: Boolean,
+ default: true,
+ },
+}, {
+ timestamps: true, // Automatically adds createdAt and updatedAt
+});
+
+// Create indexes
+BoardMemberSchema.index({ active: 1 });
+BoardMemberSchema.index({ displayOrder: 1 });
+BoardMemberSchema.index({ roleType: 1 });
+
+const BoardMember = mongoose.models.BoardMember || mongoose.model('BoardMember', BoardMemberSchema);
+
+export default BoardMember;
\ No newline at end of file
diff --git a/frontend/lib/models/Partnership.ts b/frontend/lib/models/Partnership.ts
new file mode 100644
index 0000000..236302b
--- /dev/null
+++ b/frontend/lib/models/Partnership.ts
@@ -0,0 +1,73 @@
+import mongoose, { Schema, Document } from 'mongoose';
+
+export interface IPartnership extends Document {
+ name: string;
+ websiteUrl?: string; // Link to organization's website
+ logoUrl?: string;
+ logoBase64?: string; // Base64 encoded image (takes priority over logoUrl)
+ yearFrom: number; // Starting year
+ yearTo?: number; // Ending year (optional for ongoing partnerships)
+ displayOrder: number;
+ active: boolean;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+const PartnershipSchema = new Schema({
+ name: {
+ type: String,
+ required: true,
+ trim: true,
+ },
+ websiteUrl: {
+ type: String,
+ trim: true,
+ },
+ logoUrl: {
+ type: String,
+ trim: true,
+ },
+ logoBase64: {
+ type: String,
+ trim: true,
+ },
+ yearFrom: {
+ type: Number,
+ required: true,
+ min: 1900,
+ max: 2200,
+ },
+ yearTo: {
+ type: Number,
+ required: false,
+ min: 1900,
+ max: 2200,
+ validate: {
+ validator: function(this: IPartnership, v: number) {
+ // If yearTo is provided, it must be >= yearFrom
+ if (v !== undefined && v !== null) {
+ return v >= this.yearFrom;
+ }
+ return true;
+ },
+ message: 'End year must be greater than or equal to start year'
+ }
+ },
+ displayOrder: {
+ type: Number,
+ default: 0,
+ },
+ active: {
+ type: Boolean,
+ default: true,
+ },
+}, {
+ timestamps: true,
+});
+
+// Clear any existing cached model to ensure schema updates are applied
+if (mongoose.models.Partnership) {
+ delete mongoose.models.Partnership;
+}
+
+export default mongoose.model('Partnership', PartnershipSchema);
\ No newline at end of file
diff --git a/frontend/lib/models/Project.ts b/frontend/lib/models/Project.ts
new file mode 100644
index 0000000..1a03f0a
--- /dev/null
+++ b/frontend/lib/models/Project.ts
@@ -0,0 +1,70 @@
+import mongoose, { Schema, Document } from 'mongoose';
+
+export interface IProject extends Document {
+ title: string;
+ slug: string;
+ description?: string; // Optional description
+ imageUrl?: string;
+ imageBase64?: string; // Base64 encoded image (takes priority over imageUrl)
+ tags?: string;
+ status: 'planned' | 'active' | 'completed';
+ links?: string; // JSON string
+ displayOrder: number;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+const ProjectSchema = new Schema({
+ title: {
+ type: String,
+ required: true,
+ trim: true,
+ },
+ slug: {
+ type: String,
+ required: true,
+ unique: true,
+ trim: true,
+ lowercase: true,
+ },
+ description: {
+ type: String,
+ required: false, // Made optional
+ trim: true,
+ },
+ imageUrl: {
+ type: String,
+ trim: true,
+ },
+ imageBase64: {
+ type: String,
+ trim: true,
+ },
+ tags: {
+ type: String,
+ trim: true,
+ },
+ status: {
+ type: String,
+ enum: ['planned', 'active', 'completed'],
+ default: 'planned',
+ },
+ links: {
+ type: String, // JSON string
+ trim: true,
+ },
+ displayOrder: {
+ type: Number,
+ default: 0,
+ },
+}, {
+ timestamps: true, // Automatically adds createdAt and updatedAt
+});
+
+// Create indexes
+ProjectSchema.index({ slug: 1 });
+ProjectSchema.index({ status: 1 });
+ProjectSchema.index({ displayOrder: 1 });
+
+// Prevent re-compilation during development
+export default mongoose.models.Project || mongoose.model('Project', ProjectSchema);
\ No newline at end of file
diff --git a/frontend/lib/models/ProjectMember.ts b/frontend/lib/models/ProjectMember.ts
new file mode 100644
index 0000000..712e8b8
--- /dev/null
+++ b/frontend/lib/models/ProjectMember.ts
@@ -0,0 +1,46 @@
+import mongoose, { Schema, Document } from 'mongoose';
+
+export interface IProjectMember extends Document {
+ projectId: mongoose.Types.ObjectId;
+ memberId: mongoose.Types.ObjectId;
+ role: 'member' | 'coordinator'; // Required project role enum
+ joinedAt: Date;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+const ProjectMemberSchema = new Schema({
+ projectId: {
+ type: Schema.Types.ObjectId,
+ ref: 'Project',
+ required: true,
+ },
+ memberId: {
+ type: Schema.Types.ObjectId,
+ ref: 'BoardMember',
+ required: true,
+ },
+ role: {
+ type: String,
+ enum: ['member', 'coordinator'],
+ required: true,
+ default: 'member',
+ },
+ joinedAt: {
+ type: Date,
+ default: Date.now,
+ },
+}, {
+ timestamps: true, // Automatically adds createdAt and updatedAt
+});
+
+// Create compound index to prevent duplicate assignments
+ProjectMemberSchema.index({ projectId: 1, memberId: 1 }, { unique: true });
+
+// Create indexes for efficient queries
+ProjectMemberSchema.index({ projectId: 1 });
+ProjectMemberSchema.index({ memberId: 1 });
+
+const ProjectMember = mongoose.models.ProjectMember || mongoose.model('ProjectMember', ProjectMemberSchema);
+
+export default ProjectMember;
\ No newline at end of file
diff --git a/frontend/lib/mongodb.ts b/frontend/lib/mongodb.ts
new file mode 100644
index 0000000..bba44e2
--- /dev/null
+++ b/frontend/lib/mongodb.ts
@@ -0,0 +1,56 @@
+import mongoose from 'mongoose';
+
+function getMongoDBURI() {
+ const MONGODB_URI = process.env.MONGODB_URI;
+
+ if (!MONGODB_URI) {
+ throw new Error('Please define the MONGODB_URI environment variable inside .env or .env.local');
+ }
+
+ return MONGODB_URI;
+}
+
+/**
+ * Global is used here to maintain a cached connection across hot reloads
+ * in development. This prevents connections growing exponentially
+ * during API Route usage.
+ */
+declare global {
+ var mongoose: {
+ conn: any | null;
+ promise: Promise | null;
+ };
+}
+
+let cached = global.mongoose;
+
+if (!cached) {
+ cached = global.mongoose = { conn: null, promise: null };
+}
+
+async function dbConnect() {
+ if (cached.conn) {
+ return cached.conn;
+ }
+
+ if (!cached.promise) {
+ const opts = {
+ bufferCommands: false,
+ };
+
+ cached.promise = mongoose.connect(getMongoDBURI(), opts).then((mongoose) => {
+ return mongoose;
+ });
+ }
+
+ try {
+ cached.conn = await cached.promise;
+ } catch (e) {
+ cached.promise = null;
+ throw e;
+ }
+
+ return cached.conn;
+}
+
+export default dbConnect;
\ No newline at end of file
diff --git a/frontend/lib/repositories/adminAuth.ts b/frontend/lib/repositories/adminAuth.ts
new file mode 100644
index 0000000..e8e479e
--- /dev/null
+++ b/frontend/lib/repositories/adminAuth.ts
@@ -0,0 +1,122 @@
+import dbConnect from '../mongodb';
+import AdminUser from '../models/AdminUser';
+
+export class AdminAuthRepository {
+ // Ensure database connection
+ private async ensureConnection() {
+ await dbConnect();
+ }
+
+ // Authenticate admin user
+ async authenticate(username: string, password: string): Promise {
+ try {
+ await this.ensureConnection();
+
+ const user = await AdminUser.findOne({
+ username: username.toLowerCase(),
+ isActive: true
+ });
+
+ if (!user) {
+ return false;
+ }
+
+ const isValid = await user.comparePassword(password);
+
+ if (isValid) {
+ // Update last login
+ user.lastLogin = new Date();
+ await user.save();
+ }
+
+ return isValid;
+ } catch (error) {
+ console.error('Authentication error:', error);
+ return false;
+ }
+ }
+
+ // Create admin user (for setup/seeding)
+ async createAdminUser(username: string, password: string, email?: string): Promise {
+ try {
+ await this.ensureConnection();
+
+ // Check if user already exists
+ const existingUser = await AdminUser.findOne({ username: username.toLowerCase() });
+ if (existingUser) {
+ console.log('Admin user already exists');
+ return false;
+ }
+
+ const adminUser = new AdminUser({
+ username: username.toLowerCase(),
+ password,
+ email,
+ isActive: true,
+ });
+
+ await adminUser.save();
+ console.log(`Admin user '${username}' created successfully`);
+ return true;
+ } catch (error) {
+ console.error('Error creating admin user:', error);
+ return false;
+ }
+ }
+
+ // Get admin user info (without password)
+ async getAdminUser(username: string) {
+ try {
+ await this.ensureConnection();
+
+ const user = await AdminUser.findOne({
+ username: username.toLowerCase(),
+ isActive: true
+ }).select('-password');
+
+ return user;
+ } catch (error) {
+ console.error('Error getting admin user:', error);
+ return null;
+ }
+ }
+
+ // Update admin password
+ async updatePassword(username: string, newPassword: string): Promise {
+ try {
+ await this.ensureConnection();
+
+ const user = await AdminUser.findOne({
+ username: username.toLowerCase(),
+ isActive: true
+ });
+
+ if (!user) {
+ return false;
+ }
+
+ user.password = newPassword;
+ await user.save();
+
+ return true;
+ } catch (error) {
+ console.error('Error updating password:', error);
+ return false;
+ }
+ }
+
+ // Check if any admin users exist
+ async hasAdminUsers(): Promise {
+ try {
+ await this.ensureConnection();
+ const count = await AdminUser.countDocuments({ isActive: true });
+ return count > 0;
+ } catch (error) {
+ console.error('Error checking admin users:', error);
+ return false;
+ }
+ }
+}
+
+// Export a singleton instance
+export const adminAuthRepo = new AdminAuthRepository();
\ No newline at end of file
diff --git a/frontend/lib/repositories/boardMembers.ts b/frontend/lib/repositories/boardMembers.ts
new file mode 100644
index 0000000..472e02e
--- /dev/null
+++ b/frontend/lib/repositories/boardMembers.ts
@@ -0,0 +1,288 @@
+import dbConnect from '../mongodb';
+import BoardMember from '../models/BoardMember';
+import { BoardMember as BoardMemberType, BoardMemberInput } from '../types';
+
+export class BoardMembersRepository {
+ // Ensure database connection
+ private async ensureConnection() {
+ await dbConnect();
+ }
+
+ // Convert MongoDB document to our BoardMember type
+ private documentToBoardMember(doc: any): BoardMemberType {
+ return {
+ id: doc._id?.toString() || doc.id,
+ name: doc.name,
+ role: doc.role || undefined, // Explicitly handle missing role field
+ roleType: doc.roleType || 'board_member', // Default to board_member for existing records
+ photoUrl: doc.photoUrl || '',
+ photoBase64: doc.photoBase64 || '',
+ bio: doc.bio || '',
+ socials: doc.socials || '',
+ displayOrder: doc.displayOrder,
+ active: doc.active,
+ createdAt: doc.createdAt?.toISOString ? doc.createdAt.toISOString() : doc.createdAt,
+ updatedAt: doc.updatedAt?.toISOString ? doc.updatedAt.toISOString() : doc.updatedAt,
+ };
+ }
+
+ // Get all board members, optionally filtered by active status
+ async findAll(activeOnly: boolean = false): Promise {
+ await this.ensureConnection();
+
+ const query = activeOnly ? { active: true } : {};
+ const boardMembers = await BoardMember.find(query)
+ .sort({ displayOrder: 1, createdAt: 1 })
+ .lean();
+
+ return boardMembers.map(doc => this.documentToBoardMember(doc));
+ }
+
+ // Get active board members for public display
+ async findActive(): Promise {
+ return this.findAll(true);
+ }
+
+ // Get board members by role type
+ async findByRoleType(roleType: 'board_member' | 'coordinator' | 'member', activeOnly: boolean = false): Promise {
+ await this.ensureConnection();
+
+ const filter: any = { roleType };
+ if (activeOnly) {
+ filter.active = true;
+ }
+
+ const boardMembers = await BoardMember.find(filter)
+ .sort({ displayOrder: 1, createdAt: 1 })
+ .lean();
+
+ return boardMembers.map(doc => this.documentToBoardMember(doc));
+ }
+
+ // Get board members grouped by role type
+ async findGroupedByRoleType(activeOnly: boolean = false): Promise<{
+ boardMembers: BoardMemberType[];
+ coordinators: BoardMemberType[];
+ members: BoardMemberType[];
+ }> {
+ const [boardMembers, coordinators, members] = await Promise.all([
+ this.findByRoleType('board_member', activeOnly),
+ this.findByRoleType('coordinator', activeOnly),
+ this.findByRoleType('member', activeOnly),
+ ]);
+
+ return { boardMembers, coordinators, members };
+ }
+
+ // Get statistics by role type
+ async getStatsByRoleType(): Promise<{
+ boardMembers: { total: number; active: number };
+ coordinators: { total: number; active: number };
+ members: { total: number; active: number };
+ total: { total: number; active: number };
+ }> {
+ await this.ensureConnection();
+
+ const [
+ totalBoardMembers,
+ activeBoardMembers,
+ totalCoordinators,
+ activeCoordinators,
+ totalMembers,
+ activeMembers,
+ totalAll,
+ activeAll,
+ ] = await Promise.all([
+ BoardMember.countDocuments({ roleType: 'board_member' }),
+ BoardMember.countDocuments({ roleType: 'board_member', active: true }),
+ BoardMember.countDocuments({ roleType: 'coordinator' }),
+ BoardMember.countDocuments({ roleType: 'coordinator', active: true }),
+ BoardMember.countDocuments({ roleType: 'member' }),
+ BoardMember.countDocuments({ roleType: 'member', active: true }),
+ BoardMember.countDocuments({}),
+ BoardMember.countDocuments({ active: true }),
+ ]);
+
+ return {
+ boardMembers: { total: totalBoardMembers, active: activeBoardMembers },
+ coordinators: { total: totalCoordinators, active: activeCoordinators },
+ members: { total: totalMembers, active: activeMembers },
+ total: { total: totalAll, active: activeAll },
+ };
+ }
+
+ // Get a board member by ID
+ async findById(id: string): Promise {
+ await this.ensureConnection();
+
+ const member = await BoardMember.findById(id).lean();
+ if (!member) return null;
+
+ return this.documentToBoardMember(member);
+ }
+
+ // Create a new board member
+ async create(memberData: BoardMemberInput): Promise {
+ await this.ensureConnection();
+
+ // Prepare member data, excluding empty role field
+ const memberDoc: any = {
+ name: memberData.name,
+ roleType: memberData.roleType || 'board_member',
+ photoUrl: memberData.photoUrl || '',
+ photoBase64: memberData.photoBase64 || '',
+ bio: memberData.bio || '',
+ socials: memberData.socials || '',
+ displayOrder: memberData.displayOrder || 0,
+ active: true, // Default to active for new members
+ };
+
+ // Only include role if it's not empty
+ if (memberData.role && memberData.role.trim() !== '') {
+ memberDoc.role = memberData.role;
+ }
+
+ const member = new BoardMember(memberDoc);
+ const savedMember = await member.save();
+ return this.documentToBoardMember(savedMember.toObject());
+ }
+
+ // Update an existing board member
+ async update(id: string, memberData: Partial): Promise {
+ await this.ensureConnection();
+
+ // Prepare update operations
+ const updateData: any = { ...memberData, updatedAt: new Date() };
+ const unsetData: any = {};
+
+ // Handle role field - if empty, unset it completely
+ if (memberData.role === '' || memberData.role === null || memberData.role === undefined) {
+ delete updateData.role; // Remove from update data
+ unsetData.role = ""; // Add to unset operation
+ }
+
+ // Build the update query
+ const updateQuery: any = {};
+ if (Object.keys(updateData).length > 0) {
+ updateQuery.$set = updateData;
+ }
+ if (Object.keys(unsetData).length > 0) {
+ updateQuery.$unset = unsetData;
+ }
+
+ const updatedMember = await BoardMember.findByIdAndUpdate(
+ id,
+ updateQuery,
+ { new: true, runValidators: true }
+ );
+
+ if (!updatedMember) {
+ throw new Error(`Board member with ID ${id} not found`);
+ }
+
+ return this.documentToBoardMember(updatedMember.toObject());
+ }
+
+ // Delete a board member
+ async delete(id: string): Promise {
+ await this.ensureConnection();
+
+ const deletedMember = await BoardMember.findByIdAndDelete(id);
+ if (!deletedMember) {
+ throw new Error(`Board member with ID ${id} not found`);
+ }
+ }
+
+ // Soft delete (set active to false)
+ async deactivate(id: string): Promise {
+ await this.ensureConnection();
+
+ const updatedMember = await BoardMember.findByIdAndUpdate(
+ id,
+ { active: false, updatedAt: new Date() },
+ { new: true, runValidators: true }
+ );
+
+ if (!updatedMember) {
+ throw new Error(`Board member with ID ${id} not found`);
+ }
+
+ return this.documentToBoardMember(updatedMember.toObject());
+ }
+
+ // Reactivate a board member
+ async activate(id: string): Promise {
+ await this.ensureConnection();
+
+ const updatedMember = await BoardMember.findByIdAndUpdate(
+ id,
+ { active: true, updatedAt: new Date() },
+ { new: true, runValidators: true }
+ );
+
+ if (!updatedMember) {
+ throw new Error(`Board member with ID ${id} not found`);
+ }
+
+ return this.documentToBoardMember(updatedMember.toObject());
+ }
+
+ // Get board members by role
+ async findByRole(role: string): Promise {
+ await this.ensureConnection();
+
+ const members = await BoardMember.find({ role, active: true })
+ .sort({ displayOrder: 1, createdAt: 1 })
+ .lean();
+
+ return members.map(doc => this.documentToBoardMember(doc));
+ }
+
+ // Get current leadership (President, Vice President, etc.)
+ async findLeadership(): Promise {
+ await this.ensureConnection();
+
+ const leadershipRoles = ['President', 'Vice President', 'Secretary', 'Treasurer'];
+
+ const members = await BoardMember.find({
+ role: { $in: leadershipRoles },
+ active: true
+ }).lean();
+
+ // Sort by role hierarchy
+ const sortedMembers = members.sort((a, b) => {
+ const roleOrder = {
+ 'President': 1,
+ 'Vice President': 2,
+ 'Secretary': 3,
+ 'Treasurer': 4
+ };
+
+ const aOrder = roleOrder[a.role as keyof typeof roleOrder] || 5;
+ const bOrder = roleOrder[b.role as keyof typeof roleOrder] || 5;
+
+ if (aOrder !== bOrder) {
+ return aOrder - bOrder;
+ }
+
+ return a.displayOrder - b.displayOrder;
+ });
+
+ return sortedMembers.map(doc => this.documentToBoardMember(doc));
+ }
+
+ // Reorder board members
+ async updateDisplayOrder(memberOrders: { id: string; displayOrder: number }[]): Promise {
+ await this.ensureConnection();
+
+ for (const { id, displayOrder } of memberOrders) {
+ await BoardMember.findByIdAndUpdate(
+ id,
+ { displayOrder, updatedAt: new Date() }
+ );
+ }
+ }
+}
+
+// Export a singleton instance
+export const boardMembersRepo = new BoardMembersRepository();
\ No newline at end of file
diff --git a/frontend/lib/repositories/index.ts b/frontend/lib/repositories/index.ts
new file mode 100644
index 0000000..a769663
--- /dev/null
+++ b/frontend/lib/repositories/index.ts
@@ -0,0 +1,6 @@
+// Repository exports for MongoDB-based data access
+export { projectsRepo, ProjectsRepository } from './projects';
+export { boardMembersRepo, BoardMembersRepository } from './boardMembers';
+
+// Note: Old JSON-based storage has been replaced with MongoDB
+// All repositories now use MongoDB with Mongoose ODM
\ No newline at end of file
diff --git a/frontend/lib/repositories/partnerships.ts b/frontend/lib/repositories/partnerships.ts
new file mode 100644
index 0000000..92c2f38
--- /dev/null
+++ b/frontend/lib/repositories/partnerships.ts
@@ -0,0 +1,114 @@
+import dbConnect from '../mongodb';
+import Partnership from '../models/Partnership';
+import { Partnership as PartnershipType, PartnershipInput } from '../types';
+
+export class PartnershipsRepository {
+ // Ensure database connection
+ private async ensureConnection() {
+ await dbConnect();
+ }
+
+ // Convert MongoDB document to our Partnership type
+ private documentToPartnership(doc: any): PartnershipType {
+ return {
+ id: doc._id?.toString() || doc.id,
+ name: doc.name,
+ websiteUrl: doc.websiteUrl || '',
+ logoUrl: doc.logoUrl || '',
+ logoBase64: doc.logoBase64 || '',
+ yearFrom: doc.yearFrom,
+ yearTo: doc.yearTo,
+ displayOrder: doc.displayOrder,
+ active: doc.active,
+ createdAt: doc.createdAt?.toISOString ? doc.createdAt.toISOString() : doc.createdAt,
+ updatedAt: doc.updatedAt?.toISOString ? doc.updatedAt.toISOString() : doc.updatedAt,
+ };
+ }
+
+ // Get all partnerships, optionally filtered by active status
+ async findAll(activeOnly: boolean = false): Promise {
+ await this.ensureConnection();
+
+ const query = activeOnly ? { active: true } : {};
+ const partnerships = await Partnership.find(query)
+ .sort({ displayOrder: 1, createdAt: 1 })
+ .lean();
+
+ return partnerships.map(doc => this.documentToPartnership(doc));
+ }
+
+ // Get active partnerships for public display
+ async findActive(): Promise {
+ return this.findAll(true);
+ }
+
+ // Get a single partnership by ID
+ async findById(id: string): Promise {
+ await this.ensureConnection();
+
+ const partnership = await Partnership.findById(id).lean();
+ if (!partnership) return null;
+
+ return this.documentToPartnership(partnership);
+ }
+
+ // Create a new partnership
+ async create(data: PartnershipInput): Promise {
+ await this.ensureConnection();
+
+ const partnership = new Partnership(data);
+ const savedPartnership = await partnership.save();
+
+ return this.documentToPartnership(savedPartnership);
+ }
+
+ // Update an existing partnership
+ async update(id: string, data: Partial): Promise {
+ await this.ensureConnection();
+
+ const partnership = await Partnership.findByIdAndUpdate(
+ id,
+ { $set: data },
+ { new: true, runValidators: true }
+ ).lean();
+
+ if (!partnership) return null;
+
+ return this.documentToPartnership(partnership);
+ }
+
+ // Delete a partnership
+ async delete(id: string): Promise {
+ await this.ensureConnection();
+
+ const result = await Partnership.findByIdAndDelete(id);
+ return !!result;
+ }
+
+ // Update display orders for multiple partnerships
+ async updateDisplayOrders(updates: { id: string; displayOrder: number }[]): Promise {
+ await this.ensureConnection();
+
+ const bulkOps = updates.map(({ id, displayOrder }) => ({
+ updateOne: {
+ filter: { _id: id },
+ update: { $set: { displayOrder } }
+ }
+ }));
+
+ if (bulkOps.length > 0) {
+ await Partnership.bulkWrite(bulkOps);
+ }
+ }
+
+ // Get count of partnerships
+ async count(activeOnly: boolean = false): Promise {
+ await this.ensureConnection();
+
+ const query = activeOnly ? { active: true } : {};
+ return await Partnership.countDocuments(query);
+ }
+}
+
+// Export a singleton instance
+export const partnershipsRepo = new PartnershipsRepository();
\ No newline at end of file
diff --git a/frontend/lib/repositories/projectMembers.ts b/frontend/lib/repositories/projectMembers.ts
new file mode 100644
index 0000000..41c667c
--- /dev/null
+++ b/frontend/lib/repositories/projectMembers.ts
@@ -0,0 +1,230 @@
+import dbConnect from '../mongodb';
+import ProjectMember from '../models/ProjectMember';
+import { ProjectMemberAssignment, ProjectMemberInput } from '../types';
+
+export class ProjectMembersRepository {
+ // Ensure database connection
+ private async ensureConnection() {
+ await dbConnect();
+ }
+
+ // Convert MongoDB document to our ProjectMemberAssignment type
+ private documentToProjectMember(doc: any): ProjectMemberAssignment {
+ return {
+ id: doc._id?.toString() || doc.id,
+ projectId: doc.projectId?.toString() || doc.projectId,
+ memberId: doc.memberId?.toString() || doc.memberId,
+ role: doc.role || '',
+ joinedAt: doc.joinedAt?.toISOString ? doc.joinedAt.toISOString() : doc.joinedAt,
+ // Populated fields will be added if they exist
+ project: doc.project ? {
+ id: doc.project._id?.toString() || doc.project.id,
+ title: doc.project.title,
+ slug: doc.project.slug,
+ description: doc.project.description,
+ imageUrl: doc.project.imageUrl || '',
+ imageBase64: doc.project.imageBase64 || '',
+ tags: doc.project.tags || '',
+ status: doc.project.status,
+ links: doc.project.links || '',
+ displayOrder: doc.project.displayOrder,
+ createdAt: doc.project.createdAt?.toISOString ? doc.project.createdAt.toISOString() : doc.project.createdAt,
+ updatedAt: doc.project.updatedAt?.toISOString ? doc.project.updatedAt.toISOString() : doc.project.updatedAt,
+ } : undefined,
+ member: doc.member ? {
+ id: doc.member._id?.toString() || doc.member.id,
+ name: doc.member.name,
+ role: doc.member.role,
+ roleType: doc.member.roleType,
+ photoUrl: doc.member.photoUrl || '',
+ photoBase64: doc.member.photoBase64 || '',
+ bio: doc.member.bio || '',
+ socials: doc.member.socials || '',
+ displayOrder: doc.member.displayOrder,
+ active: doc.member.active,
+ createdAt: doc.member.createdAt?.toISOString ? doc.member.createdAt.toISOString() : doc.member.createdAt,
+ updatedAt: doc.member.updatedAt?.toISOString ? doc.member.updatedAt.toISOString() : doc.member.updatedAt,
+ } : undefined,
+ };
+ }
+
+ // Add a member to a project
+ async assignMemberToProject(data: ProjectMemberInput): Promise {
+ await this.ensureConnection();
+
+ try {
+ const projectMember = new ProjectMember({
+ projectId: data.projectId,
+ memberId: data.memberId,
+ role: data.role,
+ });
+
+ const saved = await projectMember.save();
+ return this.documentToProjectMember(saved.toObject());
+ } catch (error: any) {
+ if (error.code === 11000) {
+ throw new Error('Member is already assigned to this project');
+ }
+ throw error;
+ }
+ }
+
+ // Remove a member from a project
+ async removeMemberFromProject(projectId: string, memberId: string): Promise {
+ await this.ensureConnection();
+
+ try {
+ const result = await ProjectMember.findOneAndDelete({
+ projectId,
+ memberId,
+ });
+
+ return result !== null;
+ } catch (error) {
+ console.error('Error removing member from project:', error);
+ return false;
+ }
+ }
+
+ // Get all members for a specific project
+ async getProjectMembers(projectId: string): Promise {
+ await this.ensureConnection();
+
+ try {
+ const projectMembers = await ProjectMember.find({ projectId })
+ .populate({
+ path: 'memberId',
+ select: 'name role roleType photoUrl photoBase64 bio socials displayOrder active createdAt updatedAt'
+ })
+ .sort({ joinedAt: 1 })
+ .lean();
+
+ return projectMembers.map(doc => this.documentToProjectMember({
+ ...doc,
+ member: doc.memberId,
+ }));
+ } catch (error) {
+ console.error('Error getting project members:', error);
+ return [];
+ }
+ }
+
+ // Get all projects for a specific member
+ async getMemberProjects(memberId: string): Promise {
+ await this.ensureConnection();
+
+ try {
+ const memberProjects = await ProjectMember.find({ memberId })
+ .populate({
+ path: 'projectId',
+ select: 'title slug description imageUrl imageBase64 tags status links displayOrder createdAt updatedAt'
+ })
+ .sort({ joinedAt: 1 })
+ .lean();
+
+ return memberProjects.map(doc => this.documentToProjectMember({
+ ...doc,
+ project: doc.projectId,
+ }));
+ } catch (error) {
+ console.error('Error getting member projects:', error);
+ return [];
+ }
+ }
+
+ // Update member role in a project
+ async updateMemberRole(projectId: string, memberId: string, role: string): Promise {
+ await this.ensureConnection();
+
+ try {
+ const updated = await ProjectMember.findOneAndUpdate(
+ { projectId, memberId },
+ { role },
+ { new: true }
+ ).lean();
+
+ if (!updated) {
+ return null;
+ }
+
+ return this.documentToProjectMember(updated);
+ } catch (error) {
+ console.error('Error updating member role:', error);
+ return null;
+ }
+ }
+
+ // Get all project-member relationships
+ async findAll(): Promise {
+ await this.ensureConnection();
+
+ try {
+ const projectMembers = await ProjectMember.find({})
+ .populate({
+ path: 'projectId',
+ select: 'title slug description imageUrl imageBase64 tags status links displayOrder createdAt updatedAt'
+ })
+ .populate({
+ path: 'memberId',
+ select: 'name role roleType photoUrl photoBase64 bio socials displayOrder active createdAt updatedAt'
+ })
+ .sort({ joinedAt: 1 })
+ .lean();
+
+ return projectMembers.map(doc => this.documentToProjectMember({
+ ...doc,
+ project: doc.projectId,
+ member: doc.memberId,
+ }));
+ } catch (error) {
+ console.error('Error getting all project members:', error);
+ return [];
+ }
+ }
+
+ // Bulk assign members to project
+ async bulkAssignMembersToProject(projectId: string, memberData: Array<{ memberId: string; role?: string }>): Promise {
+ await this.ensureConnection();
+
+ try {
+ const assignments = memberData.map(data => new ProjectMember({
+ projectId,
+ memberId: data.memberId,
+ role: data.role,
+ }));
+
+ // Save individually to handle duplicates gracefully
+ const results: ProjectMemberAssignment[] = [];
+ for (const assignment of assignments) {
+ try {
+ const saved = await assignment.save();
+ results.push(this.documentToProjectMember(saved.toObject()));
+ } catch (error: any) {
+ if (error.code !== 11000) { // Skip duplicate key errors
+ console.error('Error in bulk assign:', error);
+ }
+ }
+ }
+
+ return results;
+ } catch (error) {
+ console.error('Error bulk assigning members:', error);
+ return [];
+ }
+ }
+
+ // Remove all members from a project
+ async removeAllMembersFromProject(projectId: string): Promise {
+ await this.ensureConnection();
+
+ try {
+ const result = await ProjectMember.deleteMany({ projectId });
+ return result.deletedCount || 0;
+ } catch (error) {
+ console.error('Error removing all members from project:', error);
+ return 0;
+ }
+ }
+}
+
+export const projectMembersRepo = new ProjectMembersRepository();
\ No newline at end of file
diff --git a/frontend/lib/repositories/projects.ts b/frontend/lib/repositories/projects.ts
new file mode 100644
index 0000000..865c717
--- /dev/null
+++ b/frontend/lib/repositories/projects.ts
@@ -0,0 +1,299 @@
+import dbConnect from '../mongodb';
+import Project from '../models/Project';
+import { Project as ProjectType, ProjectInput } from '../types';
+
+export class ProjectsRepository {
+ // Ensure database connection
+ private async ensureConnection() {
+ await dbConnect();
+ }
+
+ // Convert MongoDB document to our Project type
+ private documentToProject(doc: any): ProjectType {
+ return {
+ id: doc._id?.toString() || doc.id,
+ title: doc.title,
+ slug: doc.slug,
+ description: doc.description,
+ imageUrl: doc.imageUrl || '',
+ imageBase64: doc.imageBase64 || '',
+ tags: doc.tags || '',
+ status: doc.status,
+ links: doc.links || '',
+ displayOrder: doc.displayOrder,
+ createdAt: doc.createdAt?.toISOString ? doc.createdAt.toISOString() : doc.createdAt,
+ updatedAt: doc.updatedAt?.toISOString ? doc.updatedAt.toISOString() : doc.updatedAt,
+ };
+ }
+
+ // Get all projects, optionally filtered by status
+ async findAll(status?: string): Promise {
+ await this.ensureConnection();
+
+ const query = status ? { status } : {};
+ const projects = await Project.find(query)
+ .sort({ displayOrder: 1, createdAt: -1 })
+ .lean();
+
+ return projects.map(doc => this.documentToProject(doc));
+ }
+
+ // Get projects sorted by status groups (planned > active > completed) then by display order
+ async findAllByStatusGroups(): Promise {
+ await this.ensureConnection();
+
+ const projects = await Project.find({})
+ .lean();
+
+ const projectsTyped = projects.map(doc => this.documentToProject(doc));
+
+ // Sort by status priority then by display order within each status
+ return projectsTyped.sort((a, b) => {
+ const statusOrder = { 'planned': 0, 'active': 1, 'completed': 2 };
+ const aStatusOrder = statusOrder[a.status as keyof typeof statusOrder] ?? 3;
+ const bStatusOrder = statusOrder[b.status as keyof typeof statusOrder] ?? 3;
+
+ if (aStatusOrder !== bStatusOrder) {
+ return aStatusOrder - bStatusOrder;
+ }
+
+ // Within same status, sort by display order
+ return a.displayOrder - b.displayOrder;
+ });
+ }
+
+ // Get active projects for public display
+ async findActive(): Promise {
+ return this.findAll('active');
+ }
+
+ // Get a project by slug
+ async findBySlug(slug: string): Promise {
+ await this.ensureConnection();
+
+ const project = await Project.findOne({ slug }).lean();
+ if (!project) return null;
+
+ return this.documentToProject(project);
+ }
+
+ // Get a project by ID
+ async findById(id: string): Promise {
+ await this.ensureConnection();
+
+ const project = await Project.findById(id).lean();
+ if (!project) return null;
+
+ return this.documentToProject(project);
+ }
+
+ // Create a new project
+ async create(projectData: ProjectInput): Promise {
+ await this.ensureConnection();
+
+ // Check if slug already exists
+ const existingProject = await Project.findOne({ slug: projectData.slug });
+ if (existingProject) {
+ throw new Error(`Project with slug "${projectData.slug}" already exists`);
+ }
+
+ const project = new Project({
+ title: projectData.title,
+ slug: projectData.slug,
+ description: projectData.description,
+ imageUrl: projectData.imageUrl || '',
+ imageBase64: projectData.imageBase64 || '',
+ tags: projectData.tags || '',
+ status: projectData.status || 'planned',
+ links: projectData.links || '',
+ displayOrder: projectData.displayOrder || 0,
+ });
+
+ const savedProject = await project.save();
+ return this.documentToProject(savedProject.toObject());
+ }
+
+ // Update an existing project
+ async update(id: string, projectData: Partial): Promise {
+ await this.ensureConnection();
+
+ // If slug is being updated, check for conflicts
+ if (projectData.slug) {
+ const existingProject = await Project.findOne({
+ slug: projectData.slug,
+ _id: { $ne: id }
+ });
+ if (existingProject) {
+ throw new Error(`Project with slug "${projectData.slug}" already exists`);
+ }
+ }
+
+ const updatedProject = await Project.findByIdAndUpdate(
+ id,
+ { ...projectData, updatedAt: new Date() },
+ { new: true, runValidators: true }
+ );
+
+ if (!updatedProject) {
+ throw new Error(`Project with ID ${id} not found`);
+ }
+
+ return this.documentToProject(updatedProject.toObject());
+ }
+
+ // Delete a project
+ async delete(id: string): Promise {
+ await this.ensureConnection();
+
+ const deletedProject = await Project.findByIdAndDelete(id);
+ if (!deletedProject) {
+ throw new Error(`Project with ID ${id} not found`);
+ }
+ }
+
+ // Get featured projects (all statuses, but sorted by status groups)
+ async findFeatured(limit: number = 6): Promise {
+ const projects = await this.findAllByStatusGroups();
+ return projects.slice(0, limit);
+ }
+
+ // Generate a unique slug from title
+ async generateSlug(title: string): Promise {
+ await this.ensureConnection();
+
+ let baseSlug = title
+ .toLowerCase()
+ .replace(/[^a-z0-9\s-]/g, '')
+ .replace(/\s+/g, '-')
+ .replace(/-+/g, '-')
+ .trim();
+
+ let slug = baseSlug;
+ let counter = 1;
+
+ while (await Project.findOne({ slug })) {
+ slug = `${baseSlug}-${counter}`;
+ counter++;
+ }
+
+ return slug;
+ }
+
+ // Get projects with their assigned members
+ async findAllWithMembers(): Promise {
+ await this.ensureConnection();
+
+ // Import dynamically to avoid circular dependency
+ const ProjectMember = (await import('../models/ProjectMember')).default;
+
+ const projects = await Project.find({})
+ .sort({ displayOrder: 1, createdAt: -1 })
+ .lean();
+
+ const projectsWithMembers = await Promise.all(
+ projects.map(async (project) => {
+ try {
+ const members = await ProjectMember.find({ projectId: (project as any)._id })
+ .populate({
+ path: 'memberId',
+ select: 'name role roleType photoUrl photoBase64 bio socials displayOrder active'
+ })
+ .sort({ joinedAt: 1 })
+ .lean();
+
+ const formattedMembers = members.map((pm: any) => ({
+ id: pm._id.toString(),
+ projectId: pm.projectId.toString(),
+ memberId: pm.memberId._id.toString(),
+ role: pm.role as 'member' | 'coordinator',
+ joinedAt: pm.joinedAt.toISOString(),
+ member: {
+ id: pm.memberId._id.toString(),
+ name: pm.memberId.name,
+ role: pm.memberId.role,
+ roleType: pm.memberId.roleType,
+ photoUrl: pm.memberId.photoUrl || '',
+ photoBase64: pm.memberId.photoBase64 || '',
+ bio: pm.memberId.bio || '',
+ socials: pm.memberId.socials || '',
+ displayOrder: pm.memberId.displayOrder,
+ active: pm.memberId.active,
+ createdAt: pm.memberId.createdAt?.toISOString ? pm.memberId.createdAt.toISOString() : pm.memberId.createdAt,
+ updatedAt: pm.memberId.updatedAt?.toISOString ? pm.memberId.updatedAt.toISOString() : pm.memberId.updatedAt,
+ }
+ }));
+
+ return {
+ ...this.documentToProject(project),
+ members: formattedMembers
+ };
+ } catch (error) {
+ console.error(`Error fetching members for project ${(project as any)._id}:`, error);
+ return {
+ ...this.documentToProject(project),
+ members: []
+ };
+ }
+ })
+ );
+
+ return projectsWithMembers;
+ }
+
+ // Get a single project with its members
+ async findBySlugWithMembers(slug: string): Promise {
+ await this.ensureConnection();
+
+ const project = await Project.findOne({ slug }).lean();
+ if (!project) return null;
+
+ // Import dynamically to avoid circular dependency
+ const ProjectMember = (await import('../models/ProjectMember')).default;
+
+ try {
+ const members = await ProjectMember.find({ projectId: (project as any)._id })
+ .populate({
+ path: 'memberId',
+ select: 'name role roleType photoUrl photoBase64 bio socials displayOrder active'
+ })
+ .sort({ joinedAt: 1 })
+ .lean();
+
+ const formattedMembers = members.map((pm: any) => ({
+ id: pm._id.toString(),
+ projectId: pm.projectId.toString(),
+ memberId: pm.memberId._id.toString(),
+ role: pm.role as 'member' | 'coordinator',
+ joinedAt: pm.joinedAt.toISOString(),
+ member: {
+ id: pm.memberId._id.toString(),
+ name: pm.memberId.name,
+ role: pm.memberId.role,
+ roleType: pm.memberId.roleType,
+ photoUrl: pm.memberId.photoUrl || '',
+ photoBase64: pm.memberId.photoBase64 || '',
+ bio: pm.memberId.bio || '',
+ socials: pm.memberId.socials || '',
+ displayOrder: pm.memberId.displayOrder,
+ active: pm.memberId.active,
+ createdAt: pm.memberId.createdAt?.toISOString ? pm.memberId.createdAt.toISOString() : pm.memberId.createdAt,
+ updatedAt: pm.memberId.updatedAt?.toISOString ? pm.memberId.updatedAt.toISOString() : pm.memberId.updatedAt,
+ }
+ }));
+
+ return {
+ ...this.documentToProject(project),
+ members: formattedMembers
+ };
+ } catch (error) {
+ console.error(`Error fetching members for project ${(project as any)._id}:`, error);
+ return {
+ ...this.documentToProject(project),
+ members: []
+ };
+ }
+ }
+}
+
+// Export a singleton instance
+export const projectsRepo = new ProjectsRepository();
\ No newline at end of file
diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts
new file mode 100644
index 0000000..946fdf2
--- /dev/null
+++ b/frontend/lib/types.ts
@@ -0,0 +1,117 @@
+// Data model types for the Gradient Science Club website
+
+export interface Project {
+ id: string; // MongoDB ObjectId as string
+ title: string;
+ slug: string;
+ description?: string; // Optional description
+ imageUrl?: string;
+ imageBase64?: string; // Base64 encoded image (takes priority over imageUrl)
+ tags?: string; // JSON or comma-separated
+ status: 'planned' | 'active' | 'completed';
+ links?: string; // JSON-encoded
+ displayOrder: number;
+ createdAt: string;
+ updatedAt: string;
+ members?: ProjectMemberAssignment[]; // Associated members
+}
+
+export interface ProjectInput {
+ title: string;
+ slug: string;
+ description?: string;
+ imageUrl?: string;
+ imageBase64?: string;
+ tags?: string;
+ status?: 'planned' | 'active' | 'completed';
+ links?: string;
+ displayOrder?: number;
+}
+
+export interface BoardMember {
+ id: string; // MongoDB ObjectId as string
+ name: string;
+ role?: string; // Optional role - required for board_member and coordinator, optional for member
+ roleType: 'board_member' | 'coordinator' | 'member'; // Flag to distinguish between board members, coordinators, and regular members
+ photoUrl?: string;
+ photoBase64?: string; // Base64 encoded image (takes priority over photoUrl)
+ bio?: string;
+ socials?: string; // JSON-encoded
+ displayOrder: number;
+ active: boolean;
+ createdAt: string;
+ updatedAt: string;
+ projects?: ProjectMemberAssignment[]; // Associated projects
+}
+
+export interface BoardMemberInput {
+ name: string;
+ role?: string; // Optional role - required for board_member and coordinator, optional for member
+ roleType: 'board_member' | 'coordinator' | 'member';
+ photoUrl?: string;
+ photoBase64?: string;
+ bio?: string;
+ socials?: string;
+ displayOrder?: number;
+ active?: boolean;
+}
+
+export interface Partnership {
+ id: string; // MongoDB ObjectId as string
+ name: string;
+ websiteUrl?: string; // Link to organization's website
+ logoUrl?: string;
+ logoBase64?: string; // Base64 encoded image (takes priority over logoUrl)
+ yearFrom: number;
+ yearTo?: number; // Optional for ongoing partnerships
+ displayOrder: number;
+ active: boolean;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface PartnershipInput {
+ name: string;
+ websiteUrl?: string;
+ logoUrl?: string;
+ logoBase64?: string;
+ yearFrom: number;
+ yearTo?: number;
+ displayOrder?: number;
+ active?: boolean;
+}
+
+// Project role enum for members working on projects
+export type ProjectRole = 'member' | 'coordinator';
+
+// Junction table type for project-member relationships
+export interface ProjectMemberAssignment {
+ id: string;
+ projectId: string;
+ memberId: string;
+ role: ProjectRole; // Required project role: member or coordinator
+ joinedAt: string;
+ // Populated fields
+ project?: Project;
+ member?: BoardMember;
+}
+
+export interface ProjectMemberInput {
+ projectId: string;
+ memberId: string;
+ role: ProjectRole; // Required project role
+}
+
+// Parsed types for JSON fields
+export interface ProjectLinks {
+ website?: string;
+ github?: string;
+ documentation?: string;
+}
+
+export interface MemberSocials {
+ twitter?: string;
+ linkedin?: string;
+ github?: string;
+ email?: string;
+}
\ No newline at end of file
diff --git a/frontend/lib/validation.ts b/frontend/lib/validation.ts
new file mode 100644
index 0000000..302902c
--- /dev/null
+++ b/frontend/lib/validation.ts
@@ -0,0 +1,379 @@
+// Form validation utilities for the Gradient Science Club website
+import type { ProjectInput, BoardMemberInput, ProjectLinks, MemberSocials } from './types';
+
+// Error types
+export interface ValidationError {
+ field: string;
+ message: string;
+}
+
+export interface FormValidationResult {
+ isValid: boolean;
+ errors: Record;
+ fieldErrors: ValidationError[];
+}
+
+// Validation rules
+export const ValidationRules = {
+ required: (value: any, fieldName: string): string | null => {
+ if (value === null || value === undefined || (typeof value === 'string' && value.trim() === '')) {
+ return `${fieldName} is required`;
+ }
+ return null;
+ },
+
+ minLength: (value: string, min: number, fieldName: string): string | null => {
+ if (value.length < min) {
+ return `${fieldName} must be at least ${min} characters long`;
+ }
+ return null;
+ },
+
+ maxLength: (value: string, max: number, fieldName: string): string | null => {
+ if (value.length > max) {
+ return `${fieldName} must be no more than ${max} characters long`;
+ }
+ return null;
+ },
+
+ url: (value: string, fieldName: string): string | null => {
+ if (!value) return null; // Allow empty URLs
+ try {
+ new URL(value);
+ return null;
+ } catch {
+ return `${fieldName} must be a valid URL`;
+ }
+ },
+
+ email: (value: string, fieldName: string): string | null => {
+ if (!value) return null; // Allow empty emails
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(value)) {
+ return `${fieldName} must be a valid email address`;
+ }
+ return null;
+ },
+
+ slug: (value: string, fieldName: string): string | null => {
+ if (!value) return `${fieldName} is required`;
+ if (!/^[a-z0-9-]+$/.test(value)) {
+ return `${fieldName} can only contain lowercase letters, numbers, and hyphens`;
+ }
+ if (value.startsWith('-') || value.endsWith('-')) {
+ return `${fieldName} cannot start or end with a hyphen`;
+ }
+ if (value.includes('--')) {
+ return `${fieldName} cannot contain consecutive hyphens`;
+ }
+ return null;
+ },
+
+ json: (value: string, fieldName: string): string | null => {
+ if (!value) return null; // Allow empty JSON
+ try {
+ const parsed = JSON.parse(value);
+ if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
+ return `${fieldName} must be a valid JSON object`;
+ }
+ return null;
+ } catch {
+ return `${fieldName} must be valid JSON`;
+ }
+ },
+
+ number: (value: any, fieldName: string): string | null => {
+ const num = Number(value);
+ if (isNaN(num)) {
+ return `${fieldName} must be a valid number`;
+ }
+ return null;
+ },
+
+ min: (value: number, min: number, fieldName: string): string | null => {
+ if (value < min) {
+ return `${fieldName} must be at least ${min}`;
+ }
+ return null;
+ },
+
+ max: (value: number, max: number, fieldName: string): string | null => {
+ if (value > max) {
+ return `${fieldName} must be no more than ${max}`;
+ }
+ return null;
+ },
+};
+
+// Utility functions
+export const isValidUrl = (url: string): boolean => {
+ try {
+ new URL(url);
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+export const isValidEmail = (email: string): boolean => {
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ return emailRegex.test(email);
+};
+
+export const generateSlug = (title: string): string => {
+ return title
+ .toLowerCase()
+ .replace(/[^\w\s-]/g, '')
+ .replace(/\s+/g, '-')
+ .replace(/-+/g, '-')
+ .trim()
+ .replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
+};
+
+// Project validation
+export const validateProject = (data: ProjectInput): FormValidationResult => {
+ const errors: Record = {};
+ const fieldErrors: ValidationError[] = [];
+
+ // Title validation
+ const titleError = ValidationRules.required(data.title, 'Title') ||
+ ValidationRules.minLength(data.title?.trim() || '', 3, 'Title') ||
+ ValidationRules.maxLength(data.title?.trim() || '', 100, 'Title');
+ if (titleError) {
+ errors.title = titleError;
+ fieldErrors.push({ field: 'title', message: titleError });
+ }
+
+ // Slug validation
+ const slugError = ValidationRules.slug(data.slug || '', 'URL slug') ||
+ ValidationRules.maxLength(data.slug || '', 50, 'URL slug');
+ if (slugError) {
+ errors.slug = slugError;
+ fieldErrors.push({ field: 'slug', message: slugError });
+ }
+
+ // Description validation (optional) - no length requirements
+
+ // Image URL validation
+ if (data.imageUrl) {
+ const imageUrlError = ValidationRules.url(data.imageUrl, 'Image URL');
+ if (imageUrlError) {
+ errors.imageUrl = imageUrlError;
+ fieldErrors.push({ field: 'imageUrl', message: imageUrlError });
+ }
+ }
+
+ // Tags validation
+ if (data.tags) {
+ const tagsError = ValidationRules.maxLength(data.tags, 200, 'Tags');
+ if (tagsError) {
+ errors.tags = tagsError;
+ fieldErrors.push({ field: 'tags', message: tagsError });
+ }
+ }
+
+ // Links validation
+ if (data.links) {
+ const linksJsonError = ValidationRules.json(data.links, 'Links');
+ if (linksJsonError) {
+ errors.links = linksJsonError;
+ fieldErrors.push({ field: 'links', message: linksJsonError });
+ } else {
+ // Validate individual URLs in links
+ try {
+ const links: ProjectLinks = JSON.parse(data.links);
+ for (const [key, url] of Object.entries(links)) {
+ if (url && !isValidUrl(url)) {
+ const linkError = `Link "${key}" must be a valid URL`;
+ errors.links = linkError;
+ fieldErrors.push({ field: 'links', message: linkError });
+ break;
+ }
+ }
+ } catch {
+ // Already handled by JSON validation above
+ }
+ }
+ }
+
+ // Display order validation
+ if (data.displayOrder !== undefined) {
+ const displayOrderError = ValidationRules.number(data.displayOrder, 'Display order') ||
+ ValidationRules.min(Number(data.displayOrder), 0, 'Display order');
+ if (displayOrderError) {
+ errors.displayOrder = displayOrderError;
+ fieldErrors.push({ field: 'displayOrder', message: displayOrderError });
+ }
+ }
+
+ return {
+ isValid: fieldErrors.length === 0,
+ errors,
+ fieldErrors,
+ };
+};
+
+// Board member validation
+export const validateBoardMember = (data: BoardMemberInput): FormValidationResult => {
+ const errors: Record = {};
+ const fieldErrors: ValidationError[] = [];
+
+ // Name validation
+ const nameError = ValidationRules.required(data.name, 'Name') ||
+ ValidationRules.minLength(data.name?.trim() || '', 2, 'Name') ||
+ ValidationRules.maxLength(data.name?.trim() || '', 100, 'Name');
+ if (nameError) {
+ errors.name = nameError;
+ fieldErrors.push({ field: 'name', message: nameError });
+ }
+
+ // Role validation - required for board_member and coordinator, optional for member
+ if (data.roleType !== 'member') {
+ const roleError = ValidationRules.required(data.role, 'Role') ||
+ ValidationRules.minLength(data.role?.trim() || '', 2, 'Role') ||
+ ValidationRules.maxLength(data.role?.trim() || '', 100, 'Role');
+ if (roleError) {
+ errors.role = roleError;
+ fieldErrors.push({ field: 'role', message: roleError });
+ }
+ } else if (data.role) {
+ // If role is provided for member type, validate its length
+ const roleError = ValidationRules.minLength(data.role?.trim() || '', 2, 'Role') ||
+ ValidationRules.maxLength(data.role?.trim() || '', 100, 'Role');
+ if (roleError) {
+ errors.role = roleError;
+ fieldErrors.push({ field: 'role', message: roleError });
+ }
+ }
+
+ // Photo URL validation
+ if (data.photoUrl) {
+ const photoUrlError = ValidationRules.url(data.photoUrl, 'Photo URL');
+ if (photoUrlError) {
+ errors.photoUrl = photoUrlError;
+ fieldErrors.push({ field: 'photoUrl', message: photoUrlError });
+ }
+ }
+
+ // Bio validation (optional) - no length requirements
+
+ // Socials validation
+ if (data.socials) {
+ const socialsJsonError = ValidationRules.json(data.socials, 'Social links');
+ if (socialsJsonError) {
+ errors.socials = socialsJsonError;
+ fieldErrors.push({ field: 'socials', message: socialsJsonError });
+ } else {
+ // Validate individual URLs/emails in socials
+ try {
+ const socials: MemberSocials = JSON.parse(data.socials);
+ for (const [platform, value] of Object.entries(socials)) {
+ if (value) {
+ // Allow both URLs and emails for social links
+ if (!isValidUrl(value) && !isValidEmail(value)) {
+ const socialError = `Social link "${platform}" must be a valid URL or email address`;
+ errors.socials = socialError;
+ fieldErrors.push({ field: 'socials', message: socialError });
+ break;
+ }
+ }
+ }
+ } catch {
+ // Already handled by JSON validation above
+ }
+ }
+ }
+
+ // Display order validation
+ if (data.displayOrder !== undefined) {
+ const displayOrderError = ValidationRules.number(data.displayOrder, 'Display order') ||
+ ValidationRules.min(Number(data.displayOrder), 0, 'Display order');
+ if (displayOrderError) {
+ errors.displayOrder = displayOrderError;
+ fieldErrors.push({ field: 'displayOrder', message: displayOrderError });
+ }
+ }
+
+ return {
+ isValid: fieldErrors.length === 0,
+ errors,
+ fieldErrors,
+ };
+};
+
+// Real-time field validation for individual fields
+export const validateField = (
+ fieldName: string,
+ value: any,
+ rules: Array<(value: any, fieldName: string) => string | null>
+): string | null => {
+ for (const rule of rules) {
+ const error = rule(value, fieldName);
+ if (error) return error;
+ }
+ return null;
+};
+
+// Debounced validation helper
+export const createDebouncedValidator = (
+ validator: (value: any) => string | null,
+ delay: number = 300
+) => {
+ let timeoutId: NodeJS.Timeout;
+
+ return (value: any, callback: (error: string | null) => void) => {
+ clearTimeout(timeoutId);
+ timeoutId = setTimeout(() => {
+ const error = validator(value);
+ callback(error);
+ }, delay);
+ };
+};
+
+// Auto-save functionality helpers
+export interface AutoSaveOptions {
+ key: string;
+ delay: number;
+ onSave?: (data: any) => void;
+ onError?: (error: Error) => void;
+}
+
+export const createAutoSave = (options: AutoSaveOptions) => {
+ let timeoutId: NodeJS.Timeout;
+
+ return (data: any) => {
+ clearTimeout(timeoutId);
+ timeoutId = setTimeout(() => {
+ try {
+ localStorage.setItem(`autosave_${options.key}`, JSON.stringify({
+ data,
+ timestamp: Date.now(),
+ }));
+ options.onSave?.(data);
+ } catch (error) {
+ options.onError?.(error as Error);
+ }
+ }, options.delay);
+ };
+};
+
+export const getAutoSavedData = (key: string, maxAge: number = 24 * 60 * 60 * 1000): any | null => {
+ try {
+ const saved = localStorage.getItem(`autosave_${key}`);
+ if (!saved) return null;
+
+ const { data, timestamp } = JSON.parse(saved);
+ if (Date.now() - timestamp > maxAge) {
+ localStorage.removeItem(`autosave_${key}`);
+ return null;
+ }
+
+ return data;
+ } catch {
+ return null;
+ }
+};
+
+export const clearAutoSavedData = (key: string) => {
+ localStorage.removeItem(`autosave_${key}`);
+};
\ No newline at end of file
diff --git a/frontend/middleware.ts b/frontend/middleware.ts
new file mode 100644
index 0000000..7eab758
--- /dev/null
+++ b/frontend/middleware.ts
@@ -0,0 +1,45 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+export function middleware(request: NextRequest) {
+ // Only apply auth to admin routes
+ if (request.nextUrl.pathname.startsWith('/admin') ||
+ request.nextUrl.pathname.startsWith('/api/admin')) {
+
+ const basicAuth = request.headers.get('authorization');
+
+ if (basicAuth) {
+ const authValue = basicAuth.split(' ')[1];
+ const [user, pwd] = atob(authValue).split(':');
+
+ // For admin routes, we'll validate credentials in the route handlers
+ // Middleware just checks for presence of credentials
+ // The actual database validation happens in each protected route
+ if (user && pwd) {
+ // Add auth info to headers for route handlers to validate
+ const requestHeaders = new Headers(request.headers);
+ requestHeaders.set('x-auth-user', user);
+ requestHeaders.set('x-auth-pass', pwd);
+
+ return NextResponse.next({
+ request: {
+ headers: requestHeaders,
+ },
+ });
+ }
+ }
+
+ // Authentication failed - no credentials provided
+ return new NextResponse('Authentication required', {
+ status: 401,
+ headers: {
+ 'WWW-Authenticate': 'Basic realm="Admin Area"',
+ },
+ });
+ }
+
+ return NextResponse.next();
+}
+
+export const config = {
+ matcher: ['/admin/:path*', '/api/admin/:path*'],
+};
\ No newline at end of file
diff --git a/frontend/next.config.js b/frontend/next.config.js
index 658404a..5661f06 100644
--- a/frontend/next.config.js
+++ b/frontend/next.config.js
@@ -1,4 +1,17 @@
/** @type {import('next').NextConfig} */
-const nextConfig = {};
+const nextConfig = {
+ images: {
+ remotePatterns: [
+ {
+ protocol: 'https',
+ hostname: 'images.unsplash.com',
+ port: '',
+ pathname: '/**',
+ },
+ ],
+ },
+ // Ensure proper CSS loading
+ swcMinify: true,
+};
module.exports = nextConfig;
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 5fb4cde..ef88ea7 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,17 +8,36 @@
"name": "frontend",
"version": "0.1.0",
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
+ "@hookform/resolvers": "^5.2.2",
+ "@radix-ui/react-alert-dialog": "^1.1.15",
+ "@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-icons": "^1.3.0",
- "@radix-ui/react-slot": "^1.0.2",
+ "@radix-ui/react-label": "^2.1.7",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-slot": "^1.2.3",
+ "@radix-ui/react-toast": "^1.2.15",
+ "@types/bcryptjs": "^2.4.6",
+ "bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
+ "dotenv": "^17.2.2",
+ "lucide-react": "^0.544.0",
+ "mongoose": "^8.18.2",
"next": "14.0.4",
"react": "^18",
"react-dom": "^18",
+ "react-hook-form": "^7.63.0",
"tailwind-merge": "^2.2.0",
- "tailwindcss-animate": "^1.0.7"
+ "tailwindcss-animate": "^1.0.7",
+ "zod": "^4.1.11"
},
"devDependencies": {
+ "@types/mongoose": "^5.11.96",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
@@ -31,6 +50,7 @@
"prettier-plugin-organize-imports": "^3.2.4",
"prettier-plugin-tailwindcss": "^0.5.10",
"tailwindcss": "^3.3.0",
+ "tsx": "^4.20.5",
"typescript": "^5"
}
},
@@ -534,379 +554,1648 @@
"node": ">=6.9.0"
}
},
- "node_modules/@eslint-community/eslint-utils": {
- "version": "4.4.0",
- "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
- "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==",
- "dev": true,
+ "node_modules/@dnd-kit/accessibility": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+ "license": "MIT",
"dependencies": {
- "eslint-visitor-keys": "^3.3.0"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "tslib": "^2.0.0"
},
"peerDependencies": {
- "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
- }
- },
- "node_modules/@eslint-community/regexpp": {
- "version": "4.10.0",
- "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz",
- "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==",
- "dev": true,
- "engines": {
- "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
- }
- },
- "node_modules/@eslint/eslintrc": {
- "version": "2.1.4",
- "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
- "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
- "dev": true,
- "dependencies": {
- "ajv": "^6.12.4",
- "debug": "^4.3.2",
- "espree": "^9.6.0",
- "globals": "^13.19.0",
- "ignore": "^5.2.0",
- "import-fresh": "^3.2.1",
- "js-yaml": "^4.1.0",
- "minimatch": "^3.1.2",
- "strip-json-comments": "^3.1.1"
- },
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
- "node_modules/@eslint/js": {
- "version": "8.56.0",
- "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz",
- "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==",
- "dev": true,
- "engines": {
- "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ "react": ">=16.8.0"
}
},
- "node_modules/@humanwhocodes/config-array": {
- "version": "0.11.13",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz",
- "integrity": "sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==",
- "dev": true,
+ "node_modules/@dnd-kit/core": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+ "license": "MIT",
"dependencies": {
- "@humanwhocodes/object-schema": "^2.0.1",
- "debug": "^4.1.1",
- "minimatch": "^3.0.5"
- },
- "engines": {
- "node": ">=10.10.0"
- }
- },
- "node_modules/@humanwhocodes/module-importer": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
- "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
- "dev": true,
- "engines": {
- "node": ">=12.22"
+ "@dnd-kit/accessibility": "^3.1.1",
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
},
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/nzakas"
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
}
},
- "node_modules/@humanwhocodes/object-schema": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz",
- "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==",
- "dev": true
- },
- "node_modules/@isaacs/cliui": {
- "version": "8.0.2",
- "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
- "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+ "node_modules/@dnd-kit/sortable": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+ "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+ "license": "MIT",
"dependencies": {
- "string-width": "^5.1.2",
- "string-width-cjs": "npm:string-width@^4.2.0",
- "strip-ansi": "^7.0.1",
- "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
- "wrap-ansi": "^8.1.0",
- "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@isaacs/cliui/node_modules/ansi-regex": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
- "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
- "engines": {
- "node": ">=12"
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
},
- "funding": {
- "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.3.0",
+ "react": ">=16.8.0"
}
},
- "node_modules/@isaacs/cliui/node_modules/strip-ansi": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
- "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+ "node_modules/@dnd-kit/utilities": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+ "license": "MIT",
"dependencies": {
- "ansi-regex": "^6.0.1"
+ "tslib": "^2.0.0"
},
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ "peerDependencies": {
+ "react": ">=16.8.0"
}
},
- "node_modules/@jridgewell/gen-mapping": {
- "version": "0.3.3",
- "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
- "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==",
- "dependencies": {
- "@jridgewell/set-array": "^1.0.1",
- "@jridgewell/sourcemap-codec": "^1.4.10",
- "@jridgewell/trace-mapping": "^0.3.9"
- },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz",
+ "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
"engines": {
- "node": ">=6.0.0"
+ "node": ">=18"
}
},
- "node_modules/@jridgewell/resolve-uri": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz",
- "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==",
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz",
+ "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
"engines": {
- "node": ">=6.0.0"
+ "node": ">=18"
}
},
- "node_modules/@jridgewell/set-array": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
- "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz",
+ "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
"engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.4.15",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
- "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg=="
- },
- "node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.20",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz",
- "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==",
- "dependencies": {
- "@jridgewell/resolve-uri": "^3.1.0",
- "@jridgewell/sourcemap-codec": "^1.4.14"
+ "node": ">=18"
}
},
- "node_modules/@next/env": {
- "version": "14.0.4",
- "resolved": "https://registry.npmjs.org/@next/env/-/env-14.0.4.tgz",
- "integrity": "sha512-irQnbMLbUNQpP1wcE5NstJtbuA/69kRfzBrpAD7Gsn8zm/CY6YQYc3HQBz8QPxwISG26tIm5afvvVbu508oBeQ=="
- },
- "node_modules/@next/eslint-plugin-next": {
- "version": "14.0.4",
- "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.0.4.tgz",
- "integrity": "sha512-U3qMNHmEZoVmHA0j/57nRfi3AscXNvkOnxDmle/69Jz/G0o/gWjXTDdlgILZdrxQ0Lw/jv2mPW8PGy0EGIHXhQ==",
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz",
+ "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==",
+ "cpu": [
+ "x64"
+ ],
"dev": true,
- "dependencies": {
- "glob": "7.1.7"
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
}
},
- "node_modules/@next/swc-darwin-arm64": {
- "version": "14.0.4",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.4.tgz",
- "integrity": "sha512-mF05E/5uPthWzyYDyptcwHptucf/jj09i2SXBPwNzbgBNc+XnwzrL0U6BmPjQeOL+FiB+iG1gwBeq7mlDjSRPg==",
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz",
+ "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==",
"cpu": [
"arm64"
],
+ "dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
- "node": ">= 10"
+ "node": ">=18"
}
},
- "node_modules/@next/swc-darwin-x64": {
- "version": "14.0.4",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.4.tgz",
- "integrity": "sha512-IZQ3C7Bx0k2rYtrZZxKKiusMTM9WWcK5ajyhOZkYYTCc8xytmwSzR1skU7qLgVT/EY9xtXDG0WhY6fyujnI3rw==",
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz",
+ "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==",
"cpu": [
"x64"
],
+ "dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
- "node": ">= 10"
+ "node": ">=18"
}
},
- "node_modules/@next/swc-linux-arm64-gnu": {
- "version": "14.0.4",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.4.tgz",
- "integrity": "sha512-VwwZKrBQo/MGb1VOrxJ6LrKvbpo7UbROuyMRvQKTFKhNaXjUmKTu7wxVkIuCARAfiI8JpaWAnKR+D6tzpCcM4w==",
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz",
+ "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==",
"cpu": [
"arm64"
],
+ "dev": true,
+ "license": "MIT",
"optional": true,
"os": [
- "linux"
+ "freebsd"
],
"engines": {
- "node": ">= 10"
+ "node": ">=18"
}
},
- "node_modules/@next/swc-linux-arm64-musl": {
- "version": "14.0.4",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.4.tgz",
- "integrity": "sha512-8QftwPEW37XxXoAwsn+nXlodKWHfpMaSvt81W43Wh8dv0gkheD+30ezWMcFGHLI71KiWmHK5PSQbTQGUiidvLQ==",
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz",
+ "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==",
"cpu": [
- "arm64"
+ "x64"
],
+ "dev": true,
+ "license": "MIT",
"optional": true,
"os": [
- "linux"
+ "freebsd"
],
"engines": {
- "node": ">= 10"
+ "node": ">=18"
}
},
- "node_modules/@next/swc-linux-x64-gnu": {
- "version": "14.0.4",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.4.tgz",
- "integrity": "sha512-/s/Pme3VKfZAfISlYVq2hzFS8AcAIOTnoKupc/j4WlvF6GQ0VouS2Q2KEgPuO1eMBwakWPB1aYFIA4VNVh667A==",
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz",
+ "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==",
"cpu": [
- "x64"
+ "arm"
],
+ "dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
- "node": ">= 10"
+ "node": ">=18"
}
},
- "node_modules/@next/swc-linux-x64-musl": {
- "version": "14.0.4",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.4.tgz",
- "integrity": "sha512-m8z/6Fyal4L9Bnlxde5g2Mfa1Z7dasMQyhEhskDATpqr+Y0mjOBZcXQ7G5U+vgL22cI4T7MfvgtrM2jdopqWaw==",
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz",
+ "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==",
"cpu": [
- "x64"
+ "arm64"
],
+ "dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
- "node": ">= 10"
+ "node": ">=18"
}
},
- "node_modules/@next/swc-win32-arm64-msvc": {
- "version": "14.0.4",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.4.tgz",
- "integrity": "sha512-7Wv4PRiWIAWbm5XrGz3D8HUkCVDMMz9igffZG4NB1p4u1KoItwx9qjATHz88kwCEal/HXmbShucaslXCQXUM5w==",
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz",
+ "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==",
"cpu": [
- "arm64"
+ "ia32"
],
+ "dev": true,
+ "license": "MIT",
"optional": true,
"os": [
- "win32"
+ "linux"
],
"engines": {
- "node": ">= 10"
+ "node": ">=18"
}
},
- "node_modules/@next/swc-win32-ia32-msvc": {
- "version": "14.0.4",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.4.tgz",
- "integrity": "sha512-zLeNEAPULsl0phfGb4kdzF/cAVIfaC7hY+kt0/d+y9mzcZHsMS3hAS829WbJ31DkSlVKQeHEjZHIdhN+Pg7Gyg==",
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz",
+ "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==",
"cpu": [
- "ia32"
+ "loong64"
],
+ "dev": true,
+ "license": "MIT",
"optional": true,
"os": [
- "win32"
+ "linux"
],
"engines": {
- "node": ">= 10"
+ "node": ">=18"
}
},
- "node_modules/@next/swc-win32-x64-msvc": {
- "version": "14.0.4",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.4.tgz",
- "integrity": "sha512-yEh2+R8qDlDCjxVpzOTEpBLQTEFAcP2A8fUFLaWNap9GitYKkKv1//y2S6XY6zsR4rCOPRpU7plYDR+az2n30A==",
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz",
+ "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==",
"cpu": [
- "x64"
+ "mips64el"
],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz",
+ "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz",
+ "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz",
+ "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz",
+ "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz",
+ "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz",
+ "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz",
+ "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz",
+ "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz",
+ "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz",
+ "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz",
+ "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
- "node": ">= 10"
+ "node": ">=18"
}
},
- "node_modules/@nodelib/fs.scandir": {
- "version": "2.1.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
- "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz",
+ "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz",
+ "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
+ "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==",
+ "dev": true,
"dependencies": {
- "@nodelib/fs.stat": "2.0.5",
- "run-parallel": "^1.1.9"
+ "eslint-visitor-keys": "^3.3.0"
},
"engines": {
- "node": ">= 8"
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
}
},
- "node_modules/@nodelib/fs.stat": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
- "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.10.0",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz",
+ "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==",
+ "dev": true,
"engines": {
- "node": ">= 8"
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
},
- "node_modules/@nodelib/fs.walk": {
- "version": "1.2.8",
- "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
- "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "node_modules/@eslint/eslintrc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
+ "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
+ "dev": true,
"dependencies": {
- "@nodelib/fs.scandir": "2.1.5",
- "fastq": "^1.6.0"
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^9.6.0",
+ "globals": "^13.19.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.0",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
},
"engines": {
- "node": ">= 8"
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
}
},
- "node_modules/@pkgjs/parseargs": {
- "version": "0.11.0",
- "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
- "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
- "optional": true,
+ "node_modules/@eslint/js": {
+ "version": "8.56.0",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz",
+ "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==",
+ "dev": true,
"engines": {
- "node": ">=14"
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@floating-ui/core": {
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
+ "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.10"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.7.4",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
+ "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.7.3",
+ "@floating-ui/utils": "^0.2.10"
+ }
+ },
+ "node_modules/@floating-ui/react-dom": {
+ "version": "2.1.6",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz",
+ "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/dom": "^1.7.4"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.10",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
+ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
+ "license": "MIT"
+ },
+ "node_modules/@hookform/resolvers": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
+ "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/utils": "^0.3.0"
+ },
+ "peerDependencies": {
+ "react-hook-form": "^7.55.0"
+ }
+ },
+ "node_modules/@humanwhocodes/config-array": {
+ "version": "0.11.13",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz",
+ "integrity": "sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==",
+ "dev": true,
+ "dependencies": {
+ "@humanwhocodes/object-schema": "^2.0.1",
+ "debug": "^4.1.1",
+ "minimatch": "^3.0.5"
+ },
+ "engines": {
+ "node": ">=10.10.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/object-schema": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz",
+ "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==",
+ "dev": true
+ },
+ "node_modules/@isaacs/cliui": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+ "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+ "dependencies": {
+ "string-width": "^5.1.2",
+ "string-width-cjs": "npm:string-width@^4.2.0",
+ "strip-ansi": "^7.0.1",
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+ "wrap-ansi": "^8.1.0",
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/ansi-regex": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
+ "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/strip-ansi": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+ "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
+ "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==",
+ "dependencies": {
+ "@jridgewell/set-array": "^1.0.1",
+ "@jridgewell/sourcemap-codec": "^1.4.10",
+ "@jridgewell/trace-mapping": "^0.3.9"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz",
+ "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/set-array": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
+ "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.4.15",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
+ "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg=="
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.20",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz",
+ "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@mongodb-js/saslprep": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.0.tgz",
+ "integrity": "sha512-zlayKCsIjYb7/IdfqxorK5+xUMyi4vOKcFy10wKJYc63NSdKI8mNME+uJqfatkPmOSMMUiojrL58IePKBm3gvQ==",
+ "license": "MIT",
+ "dependencies": {
+ "sparse-bitfield": "^3.0.3"
+ }
+ },
+ "node_modules/@next/env": {
+ "version": "14.0.4",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-14.0.4.tgz",
+ "integrity": "sha512-irQnbMLbUNQpP1wcE5NstJtbuA/69kRfzBrpAD7Gsn8zm/CY6YQYc3HQBz8QPxwISG26tIm5afvvVbu508oBeQ=="
+ },
+ "node_modules/@next/eslint-plugin-next": {
+ "version": "14.0.4",
+ "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.0.4.tgz",
+ "integrity": "sha512-U3qMNHmEZoVmHA0j/57nRfi3AscXNvkOnxDmle/69Jz/G0o/gWjXTDdlgILZdrxQ0Lw/jv2mPW8PGy0EGIHXhQ==",
+ "dev": true,
+ "dependencies": {
+ "glob": "7.1.7"
+ }
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "14.0.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.4.tgz",
+ "integrity": "sha512-mF05E/5uPthWzyYDyptcwHptucf/jj09i2SXBPwNzbgBNc+XnwzrL0U6BmPjQeOL+FiB+iG1gwBeq7mlDjSRPg==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "14.0.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.4.tgz",
+ "integrity": "sha512-IZQ3C7Bx0k2rYtrZZxKKiusMTM9WWcK5ajyhOZkYYTCc8xytmwSzR1skU7qLgVT/EY9xtXDG0WhY6fyujnI3rw==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "14.0.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.4.tgz",
+ "integrity": "sha512-VwwZKrBQo/MGb1VOrxJ6LrKvbpo7UbROuyMRvQKTFKhNaXjUmKTu7wxVkIuCARAfiI8JpaWAnKR+D6tzpCcM4w==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "14.0.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.4.tgz",
+ "integrity": "sha512-8QftwPEW37XxXoAwsn+nXlodKWHfpMaSvt81W43Wh8dv0gkheD+30ezWMcFGHLI71KiWmHK5PSQbTQGUiidvLQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "14.0.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.4.tgz",
+ "integrity": "sha512-/s/Pme3VKfZAfISlYVq2hzFS8AcAIOTnoKupc/j4WlvF6GQ0VouS2Q2KEgPuO1eMBwakWPB1aYFIA4VNVh667A==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "14.0.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.4.tgz",
+ "integrity": "sha512-m8z/6Fyal4L9Bnlxde5g2Mfa1Z7dasMQyhEhskDATpqr+Y0mjOBZcXQ7G5U+vgL22cI4T7MfvgtrM2jdopqWaw==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "14.0.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.4.tgz",
+ "integrity": "sha512-7Wv4PRiWIAWbm5XrGz3D8HUkCVDMMz9igffZG4NB1p4u1KoItwx9qjATHz88kwCEal/HXmbShucaslXCQXUM5w==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-ia32-msvc": {
+ "version": "14.0.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.4.tgz",
+ "integrity": "sha512-zLeNEAPULsl0phfGb4kdzF/cAVIfaC7hY+kt0/d+y9mzcZHsMS3hAS829WbJ31DkSlVKQeHEjZHIdhN+Pg7Gyg==",
+ "cpu": [
+ "ia32"
+ ],
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "14.0.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.4.tgz",
+ "integrity": "sha512-yEh2+R8qDlDCjxVpzOTEpBLQTEFAcP2A8fUFLaWNap9GitYKkKv1//y2S6XY6zsR4rCOPRpU7plYDR+az2n30A==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+ "optional": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@radix-ui/number": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
+ "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/primitive": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
+ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-alert-dialog": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
+ "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dialog": "1.1.15",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-arrow": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
+ "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-checkbox": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
+ "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collection": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
+ "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
+ "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
+ "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
+ "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-direction": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
+ "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dismissable-layer": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
+ "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-escape-keydown": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dropdown-menu": {
+ "version": "2.1.16",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz",
+ "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-menu": "2.1.16",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-guards": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
+ "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-scope": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
+ "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-icons": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.0.tgz",
+ "integrity": "sha512-jQxj/0LKgp+j9BiTXz3O3sgs26RNet2iLWmsPyRz2SIcR4q/4SbazXfnYwbAr+vLYKSfc7qxzyGQA1HLlYiuNw==",
+ "peerDependencies": {
+ "react": "^16.x || ^17.x || ^18.x"
+ }
+ },
+ "node_modules/@radix-ui/react-id": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
+ "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-label": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz",
+ "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menu": {
+ "version": "2.1.16",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
+ "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.8",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-roving-focus": "1.1.11",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-popper": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
+ "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react-dom": "^2.0.0",
+ "@radix-ui/react-arrow": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-use-rect": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1",
+ "@radix-ui/rect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-portal": {
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
+ "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-presence": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
+ "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
+ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-roving-focus": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
+ "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-select": {
+ "version": "2.2.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
+ "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.1",
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.8",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-visually-hidden": "1.2.3",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-toast": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz",
+ "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-visually-hidden": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-callback-ref": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
+ "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-controllable-state": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
+ "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-effect-event": "0.0.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-effect-event": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
+ "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-escape-keydown": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
+ "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
+ "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
}
},
- "node_modules/@radix-ui/react-compose-refs": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz",
- "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==",
+ "node_modules/@radix-ui/react-use-previous": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
+ "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-rect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
+ "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
+ "license": "MIT",
"dependencies": {
- "@babel/runtime": "^7.13.10"
+ "@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0"
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -914,38 +2203,65 @@
}
}
},
- "node_modules/@radix-ui/react-icons": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.0.tgz",
- "integrity": "sha512-jQxj/0LKgp+j9BiTXz3O3sgs26RNet2iLWmsPyRz2SIcR4q/4SbazXfnYwbAr+vLYKSfc7qxzyGQA1HLlYiuNw==",
+ "node_modules/@radix-ui/react-use-size": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
+ "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
"peerDependencies": {
- "react": "^16.x || ^17.x || ^18.x"
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
}
},
- "node_modules/@radix-ui/react-slot": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz",
- "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==",
+ "node_modules/@radix-ui/react-visually-hidden": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
+ "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
+ "license": "MIT",
"dependencies": {
- "@babel/runtime": "^7.13.10",
- "@radix-ui/react-compose-refs": "1.0.1"
+ "@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
- "react": "^16.8 || ^17.0 || ^18.0"
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
}
}
},
+ "node_modules/@radix-ui/rect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
+ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
+ "license": "MIT"
+ },
"node_modules/@rushstack/eslint-patch": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.6.1.tgz",
"integrity": "sha512-UY+FGM/2jjMkzQLn8pxcHGMaVLh9aEitG3zY2CiY7XHdLiz3bZOwa6oDxNqEMv7zZkV+cj5DOdz0cQ1BP5Hjgw==",
"dev": true
},
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
+ "license": "MIT"
+ },
"node_modules/@swc/helpers": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz",
@@ -979,12 +2295,28 @@
}
}
},
+ "node_modules/@types/bcryptjs": {
+ "version": "2.4.6",
+ "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
+ "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
+ "license": "MIT"
+ },
"node_modules/@types/json5": {
"version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true
},
+ "node_modules/@types/mongoose": {
+ "version": "5.11.96",
+ "resolved": "https://registry.npmjs.org/@types/mongoose/-/mongoose-5.11.96.tgz",
+ "integrity": "sha512-keiY22ljJtXyM7osgScmZOHV6eL5VFUD5tQumlu+hjS++HND5nM8jNEdj5CSWfKIJpVwQfPuwQ2SfBqUnCAVRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mongoose": "*"
+ }
+ },
"node_modules/@types/node": {
"version": "20.10.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.6.tgz",
@@ -1015,7 +2347,7 @@
"version": "18.2.18",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.18.tgz",
"integrity": "sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw==",
- "dev": true,
+ "devOptional": true,
"dependencies": {
"@types/react": "*"
}
@@ -1026,6 +2358,21 @@
"integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==",
"devOptional": true
},
+ "node_modules/@types/webidl-conversions": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
+ "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/whatwg-url": {
+ "version": "11.0.5",
+ "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz",
+ "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/webidl-conversions": "*"
+ }
+ },
"node_modules/@typescript-eslint/parser": {
"version": "6.17.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.17.0.tgz",
@@ -1246,6 +2593,18 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true
},
+ "node_modules/aria-hidden": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
+ "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
@@ -1472,6 +2831,15 @@
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
+ "node_modules/bcryptjs": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz",
+ "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==",
+ "license": "BSD-3-Clause",
+ "bin": {
+ "bcrypt": "bin/bcrypt"
+ }
+ },
"node_modules/binary-extensions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
@@ -1533,6 +2901,15 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/bson": {
+ "version": "6.10.4",
+ "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz",
+ "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=16.20.1"
+ }
+ },
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@@ -1749,7 +3126,6 @@
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
- "dev": true,
"dependencies": {
"ms": "2.1.2"
},
@@ -1808,6 +3184,12 @@
"node": ">=6"
}
},
+ "node_modules/detect-node-es": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
+ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
+ "license": "MIT"
+ },
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -1842,6 +3224,18 @@
"node": ">=6.0.0"
}
},
+ "node_modules/dotenv": {
+ "version": "17.2.2",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz",
+ "integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -1986,6 +3380,48 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/esbuild": {
+ "version": "0.25.10",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz",
+ "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.10",
+ "@esbuild/android-arm": "0.25.10",
+ "@esbuild/android-arm64": "0.25.10",
+ "@esbuild/android-x64": "0.25.10",
+ "@esbuild/darwin-arm64": "0.25.10",
+ "@esbuild/darwin-x64": "0.25.10",
+ "@esbuild/freebsd-arm64": "0.25.10",
+ "@esbuild/freebsd-x64": "0.25.10",
+ "@esbuild/linux-arm": "0.25.10",
+ "@esbuild/linux-arm64": "0.25.10",
+ "@esbuild/linux-ia32": "0.25.10",
+ "@esbuild/linux-loong64": "0.25.10",
+ "@esbuild/linux-mips64el": "0.25.10",
+ "@esbuild/linux-ppc64": "0.25.10",
+ "@esbuild/linux-riscv64": "0.25.10",
+ "@esbuild/linux-s390x": "0.25.10",
+ "@esbuild/linux-x64": "0.25.10",
+ "@esbuild/netbsd-arm64": "0.25.10",
+ "@esbuild/netbsd-x64": "0.25.10",
+ "@esbuild/openbsd-arm64": "0.25.10",
+ "@esbuild/openbsd-x64": "0.25.10",
+ "@esbuild/openharmony-arm64": "0.25.10",
+ "@esbuild/sunos-x64": "0.25.10",
+ "@esbuild/win32-arm64": "0.25.10",
+ "@esbuild/win32-ia32": "0.25.10",
+ "@esbuild/win32-x64": "0.25.10"
+ }
+ },
"node_modules/escalade": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
@@ -2646,6 +4082,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/get-nonce": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
+ "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/get-symbol-description": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz",
@@ -2663,10 +4108,11 @@
}
},
"node_modules/get-tsconfig": {
- "version": "4.7.2",
- "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.2.tgz",
- "integrity": "sha512-wuMsz4leaj5hbGgg4IvDU0bqJagpftG5l5cXIAvo8uZrqn0NJqwtfupTN00VnkQJPcIRrxYrm1Ue24btpCha2A==",
+ "version": "4.10.1",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz",
+ "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
@@ -3389,6 +4835,15 @@
"node": ">=4.0"
}
},
+ "node_modules/kareem": {
+ "version": "2.6.3",
+ "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz",
+ "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -3494,6 +4949,21 @@
"node": ">=10"
}
},
+ "node_modules/lucide-react": {
+ "version": "0.544.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.544.0.tgz",
+ "integrity": "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/memory-pager": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
+ "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
+ "license": "MIT"
+ },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -3543,11 +5013,115 @@
"node": ">=16 || 14 >=14.17"
}
},
+ "node_modules/mongodb": {
+ "version": "6.18.0",
+ "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.18.0.tgz",
+ "integrity": "sha512-fO5ttN9VC8P0F5fqtQmclAkgXZxbIkYRTUi1j8JO6IYwvamkhtYDilJr35jOPELR49zqCJgXZWwCtW7B+TM8vQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@mongodb-js/saslprep": "^1.1.9",
+ "bson": "^6.10.4",
+ "mongodb-connection-string-url": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=16.20.1"
+ },
+ "peerDependencies": {
+ "@aws-sdk/credential-providers": "^3.188.0",
+ "@mongodb-js/zstd": "^1.1.0 || ^2.0.0",
+ "gcp-metadata": "^5.2.0",
+ "kerberos": "^2.0.1",
+ "mongodb-client-encryption": ">=6.0.0 <7",
+ "snappy": "^7.2.2",
+ "socks": "^2.7.1"
+ },
+ "peerDependenciesMeta": {
+ "@aws-sdk/credential-providers": {
+ "optional": true
+ },
+ "@mongodb-js/zstd": {
+ "optional": true
+ },
+ "gcp-metadata": {
+ "optional": true
+ },
+ "kerberos": {
+ "optional": true
+ },
+ "mongodb-client-encryption": {
+ "optional": true
+ },
+ "snappy": {
+ "optional": true
+ },
+ "socks": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/mongodb-connection-string-url": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz",
+ "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/whatwg-url": "^11.0.2",
+ "whatwg-url": "^14.1.0 || ^13.0.0"
+ }
+ },
+ "node_modules/mongoose": {
+ "version": "8.18.2",
+ "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.18.2.tgz",
+ "integrity": "sha512-gA6GFlshOHUdNyw9OQTmMLSGzVOPbcbjaSZ1dvR5iMp668N2UUznTuzgTY6V6Q41VtBc4kmL/qqML1RNgXB5Fg==",
+ "license": "MIT",
+ "dependencies": {
+ "bson": "^6.10.4",
+ "kareem": "2.6.3",
+ "mongodb": "~6.18.0",
+ "mpath": "0.9.0",
+ "mquery": "5.0.0",
+ "ms": "2.1.3",
+ "sift": "17.1.3"
+ },
+ "engines": {
+ "node": ">=16.20.1"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mongoose"
+ }
+ },
+ "node_modules/mongoose/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/mpath": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz",
+ "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/mquery": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz",
+ "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4.x"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
- "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
- "dev": true
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/mz": {
"version": "2.7.0",
@@ -4232,7 +5806,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
- "dev": true,
"engines": {
"node": ">=6"
}
@@ -4279,12 +5852,97 @@
"react": "^18.2.0"
}
},
+ "node_modules/react-hook-form": {
+ "version": "7.63.0",
+ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.63.0.tgz",
+ "integrity": "sha512-ZwueDMvUeucovM2VjkCf7zIHcs1aAlDimZu2Hvel5C5907gUzMpm4xCrQXtRzCvsBqFjonB4m3x4LzCFI1ZKWA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-hook-form"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17 || ^18 || ^19"
+ }
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true
},
+ "node_modules/react-remove-scroll": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
+ "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==",
+ "license": "MIT",
+ "dependencies": {
+ "react-remove-scroll-bar": "^2.3.7",
+ "react-style-singleton": "^2.2.3",
+ "tslib": "^2.1.0",
+ "use-callback-ref": "^1.3.3",
+ "use-sidecar": "^1.1.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-remove-scroll-bar": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
+ "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
+ "license": "MIT",
+ "dependencies": {
+ "react-style-singleton": "^2.2.2",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-style-singleton": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
+ "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "get-nonce": "^1.0.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -4543,6 +6201,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/sift": {
+ "version": "17.1.3",
+ "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz",
+ "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==",
+ "license": "MIT"
+ },
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -4582,6 +6246,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/sparse-bitfield": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
+ "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
+ "license": "MIT",
+ "dependencies": {
+ "memory-pager": "^1.0.2"
+ }
+ },
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
@@ -4980,6 +6653,18 @@
"node": ">=8.0"
}
},
+ "node_modules/tr46": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/ts-api-utils": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz",
@@ -5014,6 +6699,26 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
},
+ "node_modules/tsx": {
+ "version": "4.20.5",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.5.tgz",
+ "integrity": "sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.25.0",
+ "get-tsconfig": "^4.7.5"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -5176,6 +6881,49 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/use-callback-ref": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
+ "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-sidecar": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
+ "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "detect-node-es": "^1.1.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -5193,6 +6941,28 @@
"node": ">=10.13.0"
}
},
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^5.1.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -5401,6 +7171,15 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "node_modules/zod": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz",
+ "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
}
}
}
diff --git a/frontend/package.json b/frontend/package.json
index 303a527..192ad58 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -6,20 +6,41 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
- "lint": "next lint"
+ "lint": "next lint",
+ "seed": "tsx data/seed.ts",
+ "create-admin": "tsx scripts/create-admin.ts"
},
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
+ "@hookform/resolvers": "^5.2.2",
+ "@radix-ui/react-alert-dialog": "^1.1.15",
+ "@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-icons": "^1.3.0",
- "@radix-ui/react-slot": "^1.0.2",
+ "@radix-ui/react-label": "^2.1.7",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-slot": "^1.2.3",
+ "@radix-ui/react-toast": "^1.2.15",
+ "@types/bcryptjs": "^2.4.6",
+ "bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
+ "dotenv": "^17.2.2",
+ "lucide-react": "^0.544.0",
+ "mongoose": "^8.18.2",
"next": "14.0.4",
"react": "^18",
"react-dom": "^18",
+ "react-hook-form": "^7.63.0",
"tailwind-merge": "^2.2.0",
- "tailwindcss-animate": "^1.0.7"
+ "tailwindcss-animate": "^1.0.7",
+ "zod": "^4.1.11"
},
"devDependencies": {
+ "@types/mongoose": "^5.11.96",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
@@ -32,6 +53,7 @@
"prettier-plugin-organize-imports": "^3.2.4",
"prettier-plugin-tailwindcss": "^0.5.10",
"tailwindcss": "^3.3.0",
+ "tsx": "^4.20.5",
"typescript": "^5"
}
}
diff --git a/frontend/public/data/about-benefits.json b/frontend/public/data/about-benefits.json
index fa50f79..afa03a0 100644
--- a/frontend/public/data/about-benefits.json
+++ b/frontend/public/data/about-benefits.json
@@ -3,7 +3,7 @@
{
"titleUp": "Learning",
"titleDown": "Opportunities",
- "description": "Access to experienced students and faculty for machine learning learning.",
+ "description": "Access to experienced students and faculty for studying machine learning.",
"icon": "book-solid.svg"
},
{
diff --git a/frontend/public/data/board.json b/frontend/public/data/board.json
deleted file mode 100644
index cdb5691..0000000
--- a/frontend/public/data/board.json
+++ /dev/null
@@ -1,34 +0,0 @@
-{
- "members": [
- {
- "name": "Paweł",
- "surname": "Blicharz",
- "position": "Boss",
- "icon": "mountain"
- },
- {
- "name": "Kamil",
- "surname": "Kozioł",
- "position": "Member",
- "icon": "ghost"
- },
- {
- "name": "Serhii",
- "surname": "Pyskovatskyi",
- "position": "Member",
- "icon": "fire"
- },
- {
- "name": "Krzysztof",
- "surname": "Kuchta",
- "position": "Member",
- "icon": "wheelchair"
- },
- {
- "name": "Dominika",
- "surname": "Rogowska",
- "position": "Member",
- "icon": "shield"
- }
- ]
-}
diff --git a/frontend/scripts/create-admin.ts b/frontend/scripts/create-admin.ts
new file mode 100644
index 0000000..8eed7bd
--- /dev/null
+++ b/frontend/scripts/create-admin.ts
@@ -0,0 +1,54 @@
+#!/usr/bin/env tsx
+
+import { config } from 'dotenv';
+import path from 'path';
+import fs from 'fs';
+
+// Load environment variables from multiple possible locations
+const envPaths = [
+ path.join(process.cwd(), '.env.local'),
+ path.join(process.cwd(), '.env'),
+];
+
+for (const envPath of envPaths) {
+ if (fs.existsSync(envPath)) {
+ config({ path: envPath });
+ console.log(`📁 Loaded environment from: ${envPath}`);
+ break;
+ }
+}
+
+import { adminAuthRepo } from '../lib/repositories/adminAuth';
+
+async function createAdminUser() {
+ const args = process.argv.slice(2);
+
+ if (args.length < 2) {
+ console.log('Usage: npm run create-admin [email]');
+ console.log('Example: npm run create-admin admin mypassword123 admin@example.com');
+ process.exit(1);
+ }
+
+ const [username, password, email] = args;
+
+ try {
+ console.log('🚀 Creating admin user...');
+
+ const success = await adminAuthRepo.createAdminUser(username, password, email);
+
+ if (success) {
+ console.log('✅ Admin user created successfully!');
+ console.log(`👤 Username: ${username}`);
+ console.log(`📧 Email: ${email || 'Not provided'}`);
+ console.log('🔐 Password: [hidden]');
+ } else {
+ console.log('❌ Failed to create admin user (may already exist)');
+ }
+ } catch (error) {
+ console.error('❌ Error creating admin user:', error);
+ } finally {
+ process.exit(0);
+ }
+}
+
+createAdminUser();
\ No newline at end of file
diff --git a/frontend/scripts/migrate-member-types.js b/frontend/scripts/migrate-member-types.js
new file mode 100644
index 0000000..bf8e0c6
--- /dev/null
+++ b/frontend/scripts/migrate-member-types.js
@@ -0,0 +1,80 @@
+// Migration script to update existing board members with roleType field
+// Run this with: node scripts/migrate-member-types.js
+
+const mongoose = require('mongoose');
+
+// Connect to MongoDB
+async function connectDB() {
+ try {
+ await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/gradient');
+ console.log('Connected to MongoDB');
+ } catch (error) {
+ console.error('MongoDB connection error:', error);
+ process.exit(1);
+ }
+}
+
+// Define the schema (temporary for migration)
+const BoardMemberSchema = new mongoose.Schema({
+ name: String,
+ role: String,
+ roleType: {
+ type: String,
+ enum: ['board_member', 'coordinator', 'member'],
+ default: 'member'
+ },
+ photoUrl: String,
+ photoBase64: String,
+ bio: String,
+ socials: String,
+ displayOrder: { type: Number, default: 0 },
+ active: { type: Boolean, default: true },
+}, { timestamps: true });
+
+const BoardMember = mongoose.model('BoardMember', BoardMemberSchema);
+
+async function migrateMemberTypes() {
+ try {
+ console.log('Starting migration...');
+
+ // Update all members without roleType to 'member'
+ const result1 = await BoardMember.updateMany(
+ { roleType: { $exists: false } },
+ { $set: { roleType: 'member' } }
+ );
+ console.log(`Updated ${result1.modifiedCount} members without roleType to 'member'`);
+
+ // Update members with null roleType to 'member'
+ const result2 = await BoardMember.updateMany(
+ { roleType: null },
+ { $set: { roleType: 'member' } }
+ );
+ console.log(`Updated ${result2.modifiedCount} members with null roleType to 'member'`);
+
+ // Show current distribution
+ const stats = await BoardMember.aggregate([
+ { $group: { _id: '$roleType', count: { $sum: 1 } } },
+ { $sort: { _id: 1 } }
+ ]);
+
+ console.log('\nCurrent member type distribution:');
+ stats.forEach(stat => {
+ console.log(`${stat._id}: ${stat.count}`);
+ });
+
+ console.log('\nMigration completed successfully!');
+ } catch (error) {
+ console.error('Migration error:', error);
+ } finally {
+ await mongoose.disconnect();
+ console.log('Disconnected from MongoDB');
+ }
+}
+
+// Run the migration
+async function main() {
+ await connectDB();
+ await migrateMemberTypes();
+}
+
+main().catch(console.error);
\ No newline at end of file