diff --git a/.gitignore b/.gitignore index ad25d06..602c85b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ .Spotlight-V100 .Trashes ehthumbs.db -Thumbs.db \ No newline at end of file +Thumbs.db +/frontend/.env + diff --git a/@DEVELOPMENT_ROADMAP.md b/@DEVELOPMENT_ROADMAP.md new file mode 100644 index 0000000..1cd7a01 --- /dev/null +++ b/@DEVELOPMENT_ROADMAP.md @@ -0,0 +1,258 @@ +### Gradient Science Club Website — Development Roadmap + +This roadmap will guide the build of a small, maintainable website for a science club. It prioritizes simplicity, low maintenance, and a minimal CMS for updating Projects and Board Members. We'll update this document as we progress. + +--- + +### Goals +- **Showcase club initiatives and projects**: Simple pages with project listings and details. +- **Introduce the club**: Board members page with roles and bios. +- **Minimal CMS**: Single-admin, basic CRUD for Projects and Board Members. +- **Keep it simple**: Prefer boring, proven tools new members can maintain. + +### Non‑Goals +- **No complex auth**: Only one admin account; no multi-user, roles, or OAuth. +- **No advanced CMS features**: No media library, workflows, or versioning (beyond Git). +- **No external databases**: Use a single local SQLite file; no managed DB. +- **No heavy backend**: Keep everything inside the existing Next.js app. + +--- + +### Proposed Architecture (simple by design) +- **Frontend and CMS**: Next.js (App Router) + TypeScript + Tailwind CSS (already present in `frontend/`). +- **Data storage**: + - **Current (Phase 1-2)**: JSON files for development and prototyping (`projects.json`, `board_members.json`) + - **Target (Phase 3+)**: MongoDB Atlas free cluster (cloud-hosted, managed database) +- **Data access**: Repository pattern with simple data-access layer. Currently using direct JSON file operations, will migrate to MongoDB with `mongoose` for CMS functionality. +- **Admin access**: HTTP Basic Auth via Next.js `middleware` using env variables (`ADMIN_USER`, `ADMIN_PASS`). Protects `/admin` routes and admin API endpoints. +- **Images**: Use external image URLs to avoid upload/storage complexity for now. Can add uploads later if needed. +- **Deployment**: Single Docker service with a bind-mounted `data/` volume. Serve a production build (`npm run build` + `npm run start`). + +Why not a headless CMS (Strapi, Sanity) or Git-based CMS (Decap)? They add setup, auth, hosting, or learning overhead. For a tiny site with one admin, an in-app minimal CMS is easier to own and hand over. + +**Note**: The current JSON-based storage is a temporary solution for development. The CMS (Phase 3) will migrate to MongoDB Atlas for professional database features, cloud backup, scalability, and admin interface reliability. + +--- + +### Tech Stack +- **Runtime**: Node.js 20 (LTS) +- **Web**: Next.js 14 (App Router), TypeScript, Tailwind CSS +- **DB**: + - **Current**: JSON files (`frontend/data/*.json`) + - **Target**: MongoDB Atlas (free tier, cloud-hosted) +- **DB Access**: + - **Current**: Direct JSON file operations via `fs` + - **Target**: `mongoose` ODM (for CMS) +- **Auth**: HTTP Basic Auth (env-configured) +- **Container**: Docker (multi-stage build) + Docker Compose (single service, volume for `data/`) + +--- + +### Data Model (initial) +- **Project** + - `id` (integer, PK) + - `title` (text) + - `slug` (text, unique) + - `description` (text) + - `imageUrl` (text) + - `tags` (text, comma/JSON) + - `status` (text; e.g., planned/active/completed) + - `links` (text, JSON-encoded) + - `displayOrder` (integer) + - `createdAt` (datetime) + - `updatedAt` (datetime) +- **BoardMember** + - `id` (integer, PK) + - `name` (text) + - `role` (text) + - `photoUrl` (text) + - `bio` (text) + - `socials` (text, JSON-encoded) + - `displayOrder` (integer) + - `active` (boolean) + - `createdAt` (datetime) + - `updatedAt` (datetime) + +--- + +### Routing Plan +- **Public** + - `/` — Home: brief intro, featured projects + - `/projects` — List all projects + - `/projects/[slug]` — Project detail + - `/board` — Board members + - `/about` — Club description (optional) +- **Admin (protected)** + - `/admin` — Dashboard + - `/admin/projects` — List + create/edit/delete projects + - `/admin/board` — List + create/edit/delete board members + +--- + +### Security +- **HTTP Basic Auth** for `/admin` and `/api/admin/*` via `middleware.ts`. +- **HTTPS in production** via hosting/proxy (out of scope for this repo). +- **CSRF**: Keep admin within same origin; use POST for mutations; no cross-origin. +- **Secrets**: `ADMIN_USER`, `ADMIN_PASS` stored in environment (not committed). + +--- + +### Deployment +- **Compose**: One service (Next.js app), bind-mount `./frontend/data/` to persist `site.db`. +- **Prod run**: Build once, run with `npm run start` (not dev server). +- **Backups**: Copy `frontend/data/site.db` periodically. + +--- + +### Project Structure (planned) +- `frontend/app/(public)/*` — Public pages +- `frontend/app/(admin)/*` — Admin UI +- `frontend/app/api/*` — API routes (public read + admin CRUD under `/api/admin/*`) +- `frontend/lib/db.ts` — SQLite connection helper +- `frontend/lib/repositories/*` — Data-access functions +- `frontend/data/` — SQLite file (`site.db`), schema and seed scripts +- `frontend/middleware.ts` — Basic Auth protection + +--- + +### Phased Task Breakdown + +- **Phase 0: Repo & Build Hardening** + - [x] Switch Docker to production build/run: `npm ci && npm run build` then `npm run start`. + - [x] Add `frontend/data/` directory and ensure it is persisted via Docker volume. + - [x] Document `.env` with `ADMIN_USER` and `ADMIN_PASS`. + +- **Phase 1: Data Layer** ✅ COMPLETED (JSON-based interim solution) + - [x] Add schema SQL for `projects` and `board_members` tables (for future SQLite migration). + - [x] Implement `frontend/lib/db.ts` using JSON file operations (temporary solution). + - [x] Implement repositories: `projectsRepo`, `boardMembersRepo` with CRUD. + - [x] Seed script to create JSON files and seed sample data. + +- **Phase 2: Public Pages** ✅ COMPLETED + - [x] `/projects` list page (static + revalidate) using DB reads. + - [x] `/projects/[slug]` detail page. + - [x] `/board` page with member cards. + - [x] Home page with featured projects (updated existing components). + +- **Phase 3: Admin CMS** + - **Phase 3.1: Database Migration & Auth Foundation** ✅ COMPLETED + - [x] Set up MongoDB Atlas free cluster and obtain connection string (requires user setup) + - [x] Install and configure `mongoose` package + - [x] Create MongoDB connection and database models + - [x] Migrate repository layer from JSON to MongoDB operations + - [x] Create data migration script (JSON → MongoDB) + - [x] Test database operations and ensure data integrity (requires MongoDB setup) + - [x] Implement HTTP Basic Auth middleware for `/admin` routes + - [x] Create admin environment variables and configuration + - **Phase 3.2: Admin UI Foundation** ✅ COMPLETED + - [x] Create admin layout component with navigation + - [x] Set up admin routing structure (`/admin`, `/admin/projects`, `/admin/board`) + - [x] Create admin dashboard with overview stats + - [x] Implement auth-protected admin pages + - [x] Add admin-specific styling and components + - **Phase 3.3: Projects Management** ✅ COMPLETED + - [x] Admin API routes for projects CRUD (`/api/admin/projects/*`) + - [x] Projects list page with table view and actions + - [x] Create project form with validation + - [x] Edit project form with pre-populated data + - [x] Delete project with confirmation modal + - [x] Bulk actions (delete multiple, change status) + - [x] Client-side filtering and search functionality + - [x] Toast notifications for user feedback + - [x] Bulk status updates for multiple projects + - **Phase 3.4: Board Members Management** ✅ COMPLETED + - [x] Admin API routes for board members CRUD (`/api/admin/board/*`) + - [x] Board members list page with table view and interactive functionality + - [x] Create board member form with validation and preview + - [x] Edit board member form with pre-populated data and preview + - [x] Delete board member with confirmation modal + - [x] Bulk actions (delete, activate/deactivate multiple members) + - [x] Client-side filtering and search functionality (by name, role, status) + - [x] Toast notifications for user feedback + - [x] Statistics dashboard (active members, leadership count, etc.) + - **Phase 3.5: Form Validation & UX** ✅ COMPLETED + - [x] Client-side form validation with error messages + - [x] Server-side validation and error handling + - [x] Success/error toast notifications + - [x] Loading states and form submission feedback + - [x] Auto-save drafts functionality + - [x] Form field helpers (slug generation, etc.) + - **Phase 3.6: Image Upload & Management** ✅ COMPLETED + - [x] Update database schema to support base64 image storage (BoardMember + Project) + - [x] Add image upload component with drag & drop functionality + - [x] Implement client-side image cropping and resizing (200x200 for profiles, 400x400 for projects) + - [x] Priority system: base64 images override external URLs + - [x] Add image upload to board member forms (create/edit) + - [x] Add image upload to project forms (create/edit) + - [x] Update Avatar component to handle base64 images + - [x] Update all project displays to prioritize base64 images + - [x] Add image validation (file type, size limits) + - [x] Implement image compression for optimal storage (JPEG 80% quality) + - [x] Enhanced avatar placeholders with initials and color-coded backgrounds + +- **Phase 4: Polish & Docs** + - [ ] SEO: `` tags, OpenGraph, sitemap, robots. + - [ ] Error boundaries and not-found pages. + - [ ] README for onboarding and operations (backups, env, updating content). + +- **Phase 5: Deploy** + - [ ] Update `docker-compose.yml` to mount `./frontend/data:/app/data` (or equivalent internal path). + - [ ] Run migrations/seed in entrypoint if DB is missing. + - [ ] Verify admin protection and content updates in prod. + +--- + +### Hand‑over Notes (for future club members) +- **Update content** via `/admin` using the single admin account. +- **Back up** the `site.db` file; that’s where all content lives. +- **No need** to learn a big CMS; this is intentionally small. +- **If needs grow**, consider a hosted headless CMS and swap repositories later. + +--- + +### Open Questions +- Do we need image uploads, or are URLs sufficient for now? +- Any must-have fields for projects or board members beyond the current model? +- Hosting environment and HTTPS termination preferences? + +--- + +### Change Log +- 2025-09-23: Initial roadmap created. Chose in-app minimal CMS with SQLite and Basic Auth for simplicity. +- 2025-09-24: **Phase 1 & 2 Completed**: + - Implemented data layer with JSON-based storage (interim solution for development) + - Created repository pattern for CRUD operations with file-based storage + - Developed comprehensive seed script with sample data + - Created public pages: `/projects`, `/projects/[slug]`, `/board` + - Updated existing home page components to use database + - Added responsive UI with Tailwind CSS and shadcn/ui components + - Integrated Lucide React icons for modern iconography + - Implemented proper TypeScript typing throughout + - Added SEO metadata and OpenGraph support + - Built error handling and graceful fallbacks + - Fixed build compatibility issues and ensured production-ready deployment + - Fixed navigation between home and projects pages + - **Note**: JSON storage is temporary; Phase 3 will migrate to MongoDB Atlas for CMS functionality +- 2025-09-25: **Phase 3.1-3.6 Completed**: + - Successfully migrated from JSON to MongoDB Atlas for production-ready data storage + - Implemented comprehensive admin CMS with HTTP Basic Auth protection + - Built full CRUD functionality for projects and board members management + - Added bulk operations, filtering, and search capabilities + - Created enhanced image upload system with base64 storage and automatic cropping + - Implemented intelligent avatar placeholders with initials and color-coded backgrounds + - Added drag & drop image upload with client-side processing and validation + - Base64 images take priority over external URLs for better reliability + - Profile photos automatically cropped and resized to 200x200px at 80% JPEG quality + - Project images automatically cropped and resized to 720x720px at 80% JPEG quality + - Comprehensive image management for both board members and projects + - All displays (home, list pages, detail pages) prioritize uploaded images over external URLs +- 2025-09-25: **Phase 3.5 Completed**: + - Implemented comprehensive form validation library with client-side and server-side validation + - Created enhanced FormField component with real-time validation, loading states, and character counting + - Added auto-save draft functionality with localStorage persistence and restore notifications + - Enhanced API endpoints with detailed validation error responses and better error handling + - Implemented intelligent slug generation and uniqueness validation for projects + - Added toast notifications for all form actions (success, error, auto-save, restore) + - Created debounced validation for improved UX performance + - Added form submission feedback with loading states and comprehensive error messages + - Enhanced both project and board member creation/edit forms with new validation system \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index e8bd819..ab4391a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,10 +2,12 @@ version: "3.7" services: gradient: - command: npm run dev + command: npm run start image: gradientpg/website:2.1 build: context: ./frontend dockerfile: Dockerfile ports: - - 6007:3000 \ No newline at end of file + - 6007:3000 + volumes: + - ./frontend/data:/app/data \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 62eab41..e412952 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,13 +1,31 @@ -FROM node:18-alpine +# Stage 1: build +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build -WORKDIR /frontend +# Stage 2: run +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production -COPY package*.json ./ +# Create non-root user for safety +RUN addgroup -S nextjs && adduser -S nextjs -G nextjs -RUN npm install +# Copy package files for production dependencies +COPY --from=builder /app/package*.json ./ +RUN npm ci --omit=dev && npm cache clean --force -COPY . . +# Copy built application +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public +COPY --from=builder /app/next.config.js ./next.config.js -EXPOSE 3000 +# Create data directory for SQLite +RUN mkdir -p /app/data && chown -R nextjs:nextjs /app -CMD npm run dev \ No newline at end of file +USER nextjs +EXPOSE 3000 +CMD ["npm", "run", "start"] \ No newline at end of file diff --git a/frontend/app/(home)/_components/board.tsx b/frontend/app/(home)/_components/board.tsx index 666f967..1f41486 100644 --- a/frontend/app/(home)/_components/board.tsx +++ b/frontend/app/(home)/_components/board.tsx @@ -1,47 +1,215 @@ import React from "react"; +import Link from "next/link"; import { cn } from "@/lib/utils"; -import boardInfo from "@/public/data/board.json"; -import Image from "next/image"; +import { boardMembersRepo } from "@/lib/repositories/boardMembers"; +import type { BoardMember } from "@/lib/types"; +import Avatar from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; -interface BoardProps extends React.HTMLProps {} +interface BoardProps { + className?: string; +} + +interface MemberSocials { + [key: string]: string; +} + +// Revalidate every 60 seconds to show new members +export const revalidate = 60; + +// 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): MemberSocials { + if (!socials) return {}; + try { + const parsed = JSON.parse(socials); + // Filter out empty string values + const filtered: MemberSocials = {}; + Object.entries(parsed).forEach(([key, value]) => { + if (value && typeof value === 'string' && value.trim() !== '') { + filtered[key] = value as string; + } + }); + return filtered; + } catch { + return {}; + } +} + +const Board: React.FC = async ({ ...props }) => { + const { boardMembers, coordinators, members } = await getActiveBoardMembersGrouped(); + const totalMembers = boardMembers.length + coordinators.length + members.length; -const Board: React.FC = ({ ...props }) => { - let icons_path = "/images/icons/"; return (
-

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 (
-
- icons -
-
-

- {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 && ( +
+

...and many more

+
+ )} + + {/* View All Board Members Button */} + {totalMembers > 0 && ( +
+ +
+ )}
); }; 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} + ) : ( +
+ {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.name} + {project.title} +

+ {project.description || 'No description provided.'} +

- - + + {/* Team Members */} + {project.members && project.members.length > 0 && ( +
+ Team: +
+ {project.members.slice(0, 3).map((assignment) => ( + + ))} + {project.members.length > 3 && ( +
+ +{project.members.length - 3} +
+ )} +
+
+ )} + +
+ +
+ + {project.status} + +
+
); })}
+ {projects.length === 0 && ( +
+

No projects available at the moment.

+
+ )} + + {/* View All Projects Button */} + {projects.length > 0 && ( +
+ +
+ )}
); }; 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: ( + + ), + 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 */} +
+
+ +
+

Edit Board Member

+

+ Update {member.name}'s profile +

+
+
+ +
+ {hasSavedData() && ( + + )} + + + + + + + + Delete Board Member + + Are you sure you want to delete "{member.name}"? This action cannot be undone. + + + + Cancel + + {isDeleting ? 'Deleting...' : 'Delete'} + + + + +
+
+ +
+ {/* 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 */} +
+ + + {errors.roleType && ( +

{errors.roleType}

+ )} +

+ Choose the type of membership for this person +

+
+ + {/* Photo Upload Section */} +
+ +
+ {/* Image Upload */} +
+ + setFormData(prev => ({ ...prev, photoBase64: base64 || '' }))} + cropSize={200} + maxSize={5} + /> +

+ Recommended: Upload for best quality +

+
+ + {/* URL Input */} +
+ + 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. +

+
+ +
+ +