diff --git a/app/api/announcements/route.ts b/app/api/announcements/route.ts index c922561..1251797 100644 --- a/app/api/announcements/route.ts +++ b/app/api/announcements/route.ts @@ -1,67 +1,102 @@ -import { auth } from '@clerk/nextjs/server' -import { NextRequest, NextResponse } from 'next/server' -import { connectDB } from '@/lib/mongodb' -import { Announcement } from '@/models/Announcement' -import { z } from 'zod' +import { auth } from "@clerk/nextjs/server"; +import { NextRequest, NextResponse } from "next/server"; +import { connectDB } from "@/lib/mongodb"; +import { Announcement } from "@/models/Announcement"; +import { z } from "zod"; const AnnouncementSchema = z.object({ - title: z.string().min(1), - content: z.string().min(1), - audience: z.string().optional(), - category: z.enum(['academic', 'events', 'admin', 'general']).optional(), + title: z.string().trim().min(1), + content: z.string().trim().min(1), + audience: z.enum(["All", "Students", "Staff"]).optional(), + category: z.enum(["academic", "events", "admin", "general"]).optional(), pinned: z.boolean().optional(), -}) +}); export async function GET(req: NextRequest) { - const { userId } = await auth() - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const { userId } = await auth(); + if (!userId) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); try { - await connectDB() - const { searchParams } = new URL(req.url) + await connectDB(); + const { searchParams } = new URL(req.url); // Parse and validate limit - const limitStr = searchParams.get('limit') ?? '50' - let limit = parseInt(limitStr, 10) + const limitStr = searchParams.get("limit") ?? "50"; + let limit = parseInt(limitStr, 10); if (!Number.isFinite(limit) || limit <= 0) { - limit = 50 + limit = 50; } - limit = Math.min(limit, 100) // Cap at 100 + limit = Math.min(limit, 100); // Cap at 100 const announcements = await Announcement.find({ teacherId: userId }) .sort({ pinned: -1, createdAt: -1 }) .limit(limit) - .lean() + .lean(); - return NextResponse.json(announcements) + return NextResponse.json(announcements); } catch (error) { - console.error('GET /api/announcements error:', error instanceof Error ? error.message : error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + console.error( + "GET /api/announcements error:", + error instanceof Error ? error.message : error, + ); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); } } export async function POST(req: NextRequest) { - const { userId } = await auth() - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const { userId } = await auth(); + if (!userId) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); try { - await connectDB() - let body + await connectDB(); + let body; try { - body = await req.json() + body = await req.json(); } catch { - return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }) + return NextResponse.json( + { error: "Invalid JSON in request body" }, + { status: 400 }, + ); } - - const parsed = AnnouncementSchema.safeParse(body) - if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }) - const announcement = await Announcement.create({ ...parsed.data, teacherId: userId }) - return NextResponse.json(announcement, { status: 201 }) + const recentCount = await Announcement.countDocuments({ + teacherId: userId, + createdAt: { $gte: new Date(Date.now() - 60 * 1000) }, + }); + + if (recentCount > 10) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }); + } + + const parsed = AnnouncementSchema.safeParse(body); + if (!parsed.success) + return NextResponse.json( + { error: parsed.error.flatten() }, + { status: 400 }, + ); + const data = parsed.data; + + const announcement = await Announcement.create({ + title: data.title, + content: data.content, + audience: data.audience ?? "all", + category: data.category ?? "general", + pinned: data.pinned ?? false, + teacherId: userId, + }); + return NextResponse.json(announcement, { status: 201 }); } catch (error) { if (error instanceof Error) { - console.error('POST /api/announcements error:', error.message) + console.error("POST /api/announcements error:", error.message); } - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); } } diff --git a/app/api/assignments/route.ts b/app/api/assignments/route.ts index 19021d8..3cb02e1 100644 --- a/app/api/assignments/route.ts +++ b/app/api/assignments/route.ts @@ -1,91 +1,120 @@ -import { auth } from '@clerk/nextjs/server' -import { NextRequest, NextResponse } from 'next/server' -import { connectDB } from '@/lib/mongodb' -import { Assignment } from '@/models/Assignment' -import { z } from 'zod' +import { auth } from "@clerk/nextjs/server"; +import { NextRequest, NextResponse } from "next/server"; +import { connectDB } from "@/lib/mongodb"; +import { Assignment } from "@/models/Assignment"; +import { z } from "zod"; const AssignmentSchema = z.object({ - title: z.string().min(1), - description: z.string().optional(), - subject: z.string().min(1), - class: z.string().min(1), - deadline: z.string().min(1), - maxMarks: z.number().min(1).optional(), - status: z.enum(['active', 'closed']).optional(), - kanbanStatus: z.enum(['todo', 'in_progress', 'submitted']).optional(), -}) + title: z.string().trim().min(1), + description: z.string().trim().optional(), + subject: z.string().trim().min(1), + class: z.string().trim().min(1), + deadline: z.coerce.date(), + maxMarks: z.coerce.number().min(1).optional(), + status: z.enum(["active", "closed"]).default("active"), + kanbanStatus: z.enum(["todo", "in_progress", "submitted"]).default("todo"), +}); export async function GET(req: NextRequest) { - const { userId } = await auth() - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const { userId } = await auth(); + if (!userId) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); try { - await connectDB() - const { searchParams } = new URL(req.url) - const status = searchParams.get('status') - + await connectDB(); + const { searchParams } = new URL(req.url); + const status = searchParams.get("status"); + // Parse and validate pagination - const pageStr = searchParams.get('page') ?? '1' - const limitStr = searchParams.get('limit') ?? '20' - - let page = parseInt(pageStr, 10) - let limit = parseInt(limitStr, 10) - - if (!Number.isFinite(page) || page < 1) page = 1 - if (!Number.isFinite(limit) || limit < 1) limit = 20 - limit = Math.min(limit, 100) // Cap at 100 + const pageStr = searchParams.get("page") ?? "1"; + const limitStr = searchParams.get("limit") ?? "20"; + + let page = parseInt(pageStr, 10); + let limit = parseInt(limitStr, 10); - const query: Record = { teacherId: userId } - if (status) query.status = status + if (!Number.isFinite(page) || page < 1) page = 1; + if (!Number.isFinite(limit) || limit < 1) limit = 20; + limit = Math.min(limit, 100); // Cap at 100 + + const query: Record = { teacherId: userId }; + if (status && !["active", "closed"].includes(status)) { + return NextResponse.json({ error: "Invalid status" }, { status: 400 }); + } + if (status) query.status = status; - const skip = (page - 1) * limit + const skip = (page - 1) * limit; const assignments = await Assignment.find(query) .sort({ createdAt: -1 }) .skip(skip) .limit(limit) - .lean() - - const total = await Assignment.countDocuments(query) + .lean(); - return NextResponse.json({ assignments, total, page, pages: Math.ceil(total / limit) }) + const total = await Assignment.countDocuments(query); + + return NextResponse.json({ + assignments, + total, + page, + pages: Math.ceil(total / limit), + }); } catch (error) { if (error instanceof Error) { - console.error('GET /api/assignments error:', error.message) + console.error("GET /api/assignments error:", error.message); } - return NextResponse.json({ error: error instanceof Error ? error.stack : 'Internal server error' }, { status: 500 }) + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); } } export async function POST(req: NextRequest) { - const { userId } = await auth() - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const { userId } = await auth(); + if (!userId) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); try { - await connectDB() - - let body + await connectDB(); + + let body; try { - body = await req.json() + body = await req.json(); } catch { - return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }) + return NextResponse.json( + { error: "Invalid JSON in request body" }, + { status: 400 }, + ); } - - const parsed = AssignmentSchema.safeParse(body) - if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }) + + const parsed = AssignmentSchema.safeParse(body); + if (!parsed.success) + return NextResponse.json( + { error: parsed.error.flatten() }, + { status: 400 }, + ); try { - const assignment = await Assignment.create({ ...parsed.data, teacherId: userId }) - return NextResponse.json(assignment, { status: 201 }) + const assignment = await Assignment.create({ + ...parsed.data, + teacherId: userId, + }); + return NextResponse.json(assignment, { status: 201 }); } catch (dbError) { if (dbError instanceof Error) { - console.error('Assignment.create error:', dbError.message) + console.error("Assignment.create error:", dbError.message); } - return NextResponse.json({ error: 'Failed to create assignment' }, { status: 500 }) + return NextResponse.json( + { error: "Failed to create assignment" }, + { status: 500 }, + ); } } catch (error) { if (error instanceof Error) { - console.error('POST /api/assignments error:', error.message) + console.error("POST /api/assignments error:", error.message); } - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); } } diff --git a/app/api/profile/route.ts b/app/api/profile/route.ts index a3d98bf..4f74bcd 100644 --- a/app/api/profile/route.ts +++ b/app/api/profile/route.ts @@ -1,114 +1,103 @@ -import { auth, currentUser } from '@clerk/nextjs/server' -import { NextRequest, NextResponse } from 'next/server' -import { connectDB } from '@/lib/mongodb' -import { Teacher } from '@/models/Teacher' +import { auth, currentUser } from "@clerk/nextjs/server"; +import { NextRequest, NextResponse } from "next/server"; +import { connectDB } from "@/lib/mongodb"; +import { Teacher } from "@/models/Teacher"; +import { updateSchema } from "@/lib/schemas"; export async function GET(req: NextRequest) { - const { searchParams } = new URL(req.url) - const queryUserId = searchParams.get('userId') + const { searchParams } = new URL(req.url); + const queryUserId = searchParams.get("userId"); - let userId: string | null = queryUserId + let userId: string | null = queryUserId; if (!userId) { - const session = await auth() - userId = session.userId + const session = await auth(); + userId = session.userId; } - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + if (!userId) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); try { - await connectDB() - let teacher = await Teacher.findOne({ clerkId: userId }).lean() + await connectDB(); + let teacher = await Teacher.findOne({ clerkId: userId }).lean(); if (!teacher) { - const clerkUser = await currentUser() + const clerkUser = await currentUser(); const created = await Teacher.create({ clerkId: userId, - name: clerkUser?.fullName ?? '', - email: clerkUser?.emailAddresses[0]?.emailAddress ?? '', - department: '', + name: clerkUser?.fullName ?? "", + email: clerkUser?.emailAddresses[0]?.emailAddress ?? "", + department: "", subjects: [], - }) - teacher = created.toObject() + }); + teacher = created.toObject(); } - return NextResponse.json(teacher) + return NextResponse.json(teacher); } catch (error) { - console.error('GET /api/profile error:', error instanceof Error ? error.message : error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + console.error( + "GET /api/profile error:", + error instanceof Error ? error.message : error, + ); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); } } export async function PUT(req: NextRequest) { - const { userId } = await auth() - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const { userId } = await auth(); + if (!userId) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); try { - await connectDB() - - let body + await connectDB(); + + let body; try { - body = await req.json() + body = await req.json(); } catch { - return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 }) + return NextResponse.json( + { error: "Invalid JSON in request body" }, + { status: 400 }, + ); } - - const { name, department, subjects, phone, bio, academicHistory } = body - // Validate input - if (typeof name !== 'string' || !name.trim()) { - return NextResponse.json({ error: 'name must be a non-empty string' }, { status: 400 }) - } - if (department !== undefined && typeof department !== 'string') { - return NextResponse.json({ error: 'department must be a string' }, { status: 400 }) - } - if (!Array.isArray(subjects) || !subjects.every((s) => typeof s === 'string')) { - return NextResponse.json({ error: 'subjects must be an array of strings' }, { status: 400 }) - } - if (phone !== undefined && typeof phone !== 'string') { - return NextResponse.json({ error: 'phone must be a string' }, { status: 400 }) - } - if (bio !== undefined && typeof bio !== 'string') { - return NextResponse.json({ error: 'bio must be a string' }, { status: 400 }) - } - if (academicHistory !== undefined) { - if ( - !Array.isArray(academicHistory) || - academicHistory.length > 20 || - !academicHistory.every( - (entry: unknown) => - entry !== null && - typeof entry === 'object' && - typeof (entry as Record).year === 'string' && - typeof (entry as Record).title === 'string', - ) - ) { - return NextResponse.json( - { error: 'academicHistory must be an array of objects with string year and title (max 20 items)' }, - { status: 400 }, - ) - } + const { name, department, subjects, phone, bio, academicHistory } = body; + + const parsed = updateSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.flatten() }, + { status: 400 }, + ); } - const updatePayload: Record = { name, subjects } - if (department !== undefined) updatePayload.department = department - if (phone !== undefined) updatePayload.phone = phone - if (bio !== undefined) updatePayload.bio = bio - if (academicHistory !== undefined) updatePayload.academicHistory = academicHistory + const data = parsed.data; + + const updatePayload = Object.fromEntries( + Object.entries(data).filter(([_, v]) => v !== undefined), + ); const teacher = await Teacher.findOneAndUpdate( { clerkId: userId }, { $set: updatePayload }, - { new: true } - ) - + { new: true }, + ); + if (!teacher) { - return NextResponse.json({ error: 'Teacher not found' }, { status: 404 }) + return NextResponse.json({ error: "Teacher not found" }, { status: 404 }); } - return NextResponse.json(teacher) + return NextResponse.json(teacher); } catch (error) { if (error instanceof Error) { - console.error('PUT /api/profile error:', error.message) + console.error("PUT /api/profile error:", error.message); } - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); } } diff --git a/app/dashboard/announcements/AnnouncementsClient.tsx b/app/dashboard/announcements/AnnouncementsClient.tsx index 0de73bb..7991416 100644 --- a/app/dashboard/announcements/AnnouncementsClient.tsx +++ b/app/dashboard/announcements/AnnouncementsClient.tsx @@ -1,178 +1,232 @@ -'use client' - -import { useEffect, useState, useCallback } from 'react' -import { useForm } from 'react-hook-form' -import { Button } from '@/components/ui/Button' -import { Modal } from '@/components/ui/Modal' -import { Badge } from '@/components/ui/Badge' -import { useToast } from '@/components/ui/Toast' -import { TableSkeleton } from '@/components/ui/Skeleton' +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { useForm } from "react-hook-form"; +import { Button } from "@/components/ui/Button"; +import { Modal } from "@/components/ui/Modal"; +import { Badge } from "@/components/ui/Badge"; +import { useToast } from "@/components/ui/Toast"; +import { TableSkeleton } from "@/components/ui/Skeleton"; import { ConfirmModal } from "@/components/ui/ConfirmModal"; +import { formatZodError } from "@/lib/utils"; + +type Category = "academic" | "events" | "admin" | "general"; interface Announcement { - _id: string - title: string - content: string - audience: string - category: string - pinned: boolean - createdAt: string + _id: string; + title: string; + content: string; + audience?: string; + category: Category; + pinned: boolean; + createdAt: string; } interface FormData { - title: string - content: string - audience: string - category: string - pinned: boolean + title: string; + content: string; + audience: string; + category: Category; + pinned: boolean; } -type CategoryFilter = 'all' | 'academic' | 'events' | 'admin' | 'general' +type CategoryFilter = "all" | "academic" | "events" | "admin" | "general"; const CATEGORY_FILTERS: { value: CategoryFilter; label: string }[] = [ - { value: 'all', label: 'All' }, - { value: 'academic', label: 'Academic' }, - { value: 'events', label: 'Events' }, - { value: 'admin', label: 'Admin' }, - { value: 'general', label: 'General' }, -] - -const CATEGORY_BADGE: Record = { - academic: 'info', - events: 'success', - admin: 'warning', - general: 'default', -} + { value: "all", label: "All" }, + { value: "academic", label: "Academic" }, + { value: "events", label: "Events" }, + { value: "admin", label: "Admin" }, + { value: "general", label: "General" }, +]; + +const CATEGORY_BADGE: Record< + string, + "info" | "success" | "warning" | "purple" | "default" +> = { + academic: "info", + events: "success", + admin: "warning", + general: "default", +}; function timeAgo(date: string) { - const diff = Date.now() - new Date(date).getTime() - const mins = Math.floor(diff / 60000) - if (mins < 60) return `${mins}m ago` - const hrs = Math.floor(mins / 60) - if (hrs < 24) return `${hrs}h ago` - return `${Math.floor(hrs / 24)}d ago` + const diff = Date.now() - new Date(date).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; } function useReadState() { const [readIds, setReadIds] = useState>(() => { - if (typeof window === 'undefined') return new Set() + if (typeof window === "undefined") return new Set(); try { - const stored = localStorage.getItem("announcement_read_ids") - if (stored) return new Set(JSON.parse(stored) as string[]) + const stored = localStorage.getItem("announcement_read_ids"); + if (stored) return new Set(JSON.parse(stored) as string[]); } catch { // Silently ignore JSON parse errors } - return new Set() + return new Set(); }); const markRead = useCallback((id: string) => { setReadIds((prev) => { - if (prev.has(id)) return prev - const next = new Set(prev) - next.add(id) - try { localStorage.setItem('announcement_read_ids', JSON.stringify([...next])) } catch {} - return next - }) - }, []) + if (prev.has(id)) return prev; + const next = new Set(prev); + next.add(id); + try { + localStorage.setItem( + "announcement_read_ids", + JSON.stringify([...next]), + ); + } catch {} + return next; + }); + }, []); const markAllRead = useCallback((ids: string[]) => { setReadIds((prev) => { - const next = new Set(prev) - for (const id of ids) next.add(id) - try { localStorage.setItem('announcement_read_ids', JSON.stringify([...next])) } catch {} - return next - }) - }, []) - - return { readIds, markRead, markAllRead } + const next = new Set(prev); + for (const id of ids) next.add(id); + try { + localStorage.setItem( + "announcement_read_ids", + JSON.stringify([...next]), + ); + } catch {} + return next; + }); + }, []); + + return { readIds, markRead, markAllRead }; } export function AnnouncementsClient() { - const { toast } = useToast() - const [announcements, setAnnouncements] = useState([]) - const [loading, setLoading] = useState(true) - const [modalOpen, setModalOpen] = useState(false) - const [editing, setEditing] = useState(null) - const [categoryFilter, setCategoryFilter] = useState('all') - const [expandedId, setExpandedId] = useState(null) + const { toast } = useToast(); + const [announcements, setAnnouncements] = useState([]); + const [loading, setLoading] = useState(true); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [categoryFilter, setCategoryFilter] = useState("all"); + const [expandedId, setExpandedId] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); - const { readIds, markRead, markAllRead } = useReadState() - - const { register, handleSubmit, reset, formState: { isSubmitting, errors } } = useForm() + const { readIds, markRead, markAllRead } = useReadState(); + + const { + register, + handleSubmit, + reset, + formState: { isSubmitting, errors }, + } = useForm({ + defaultValues: { + title: "", + content: "", + audience: "All", + category: "general", + pinned: false, + }, + }); const fetchAnnouncements = useCallback(async () => { - setLoading(true) + setLoading(true); try { - const res = await fetch('/api/announcements') - if (!res.ok) throw new Error(`Failed to fetch announcements: ${res.status}`) - const data = await res.json() - setAnnouncements(Array.isArray(data) ? data : []) + const res = await fetch("/api/announcements"); + if (!res.ok) + throw new Error(`Failed to fetch announcements: ${res.status}`); + const data = await res.json(); + setAnnouncements(Array.isArray(data) ? data : []); } catch (error) { - toast(error instanceof Error ? error.message : 'Failed to load', 'error') + toast(error instanceof Error ? error.message : "Failed to load", "error"); } finally { - setLoading(false) + setLoading(false); } - }, [toast]) + }, [toast]); - useEffect(() => { fetchAnnouncements() }, [fetchAnnouncements]) + useEffect(() => { + fetchAnnouncements(); + }, [fetchAnnouncements]); - const filtered = categoryFilter === 'all' - ? announcements - : announcements.filter((a) => (a.category || 'general') === categoryFilter) + const filtered = + categoryFilter === "all" + ? announcements + : announcements.filter( + (a) => (a.category || "general") === categoryFilter, + ); - const unreadCount = announcements.filter((a) => !readIds.has(a._id)).length + const unreadCount = announcements.filter((a) => !readIds.has(a._id)).length; const openAdd = () => { - setEditing(null) - reset({ audience: 'All', category: 'general', pinned: false }) - setModalOpen(true) - } + setEditing(null); + reset({ audience: "All", category: "general", pinned: false }); + setModalOpen(true); + }; const openEdit = (a: Announcement) => { - setEditing(a) - reset({ title: a.title, content: a.content, audience: a.audience, category: a.category || 'general', pinned: a.pinned }) - setModalOpen(true) - } + setEditing(a); + reset({ + title: a.title, + content: a.content, + audience: a.audience || "All", + category: a.category || "general", + pinned: a.pinned, + }); + setModalOpen(true); + }; const toggleExpand = (id: string) => { - setExpandedId((prev) => (prev === id ? null : id)) - markRead(id) - } + setExpandedId((prev) => (prev === id ? null : id)); + markRead(id); + }; const onSubmit = async (data: FormData) => { - const url = editing ? `/api/announcements/${editing._id}` : '/api/announcements' - const method = editing ? 'PUT' : 'POST' + const url = editing + ? `/api/announcements/${editing._id}` + : "/api/announcements"; + const method = editing ? "PUT" : "POST"; try { const res = await fetch(url, { method, - headers: { 'Content-Type': 'application/json' }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), - }) - if (res.ok) { - toast(editing ? 'Announcement updated!' : 'Announcement posted!', 'success') - setModalOpen(false) - fetchAnnouncements() - } else { - toast('Failed to save announcement', 'error') + }); + const result = await res.json(); + console.log(result); + if (!res.ok) { + const submitErrros = formatZodError(result.error); + submitErrros.forEach((res) => { + toast(res, "error"); + }); + return; } + + toast( + editing ? "Announcement updated!" : "Announcement posted!", + "success", + ); + setModalOpen(false); + fetchAnnouncements(); } catch (error) { - toast(error instanceof Error ? error.message : 'Network error', 'error') + toast(error instanceof Error ? error.message : "Network error", "error"); } - } + }; const togglePin = async (a: Announcement) => { try { const res = await fetch(`/api/announcements/${a._id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + method: "PUT", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ pinned: !a.pinned }), - }) - if (res.ok) { toast(a.pinned ? 'Unpinned' : 'Pinned!', 'success'); fetchAnnouncements() } - else toast('Failed to update', 'error') + }); + if (res.ok) { + toast(a.pinned ? "Unpinned" : "Pinned!", "success"); + fetchAnnouncements(); + } else toast("Failed to update", "error"); } catch (error) { - toast(error instanceof Error ? error.message : 'Network error', 'error') + toast(error instanceof Error ? error.message : "Network error", "error"); } - } + }; const executeDelete = async () => { if (!deleteTarget) return; diff --git a/app/dashboard/assignments/AssignmentsClient.tsx b/app/dashboard/assignments/AssignmentsClient.tsx index e0e7f13..79f0cbb 100644 --- a/app/dashboard/assignments/AssignmentsClient.tsx +++ b/app/dashboard/assignments/AssignmentsClient.tsx @@ -1,36 +1,36 @@ -'use client' +"use client"; import { useEffect, useState, useCallback, useRef, useMemo } from "react"; -import { useForm } from 'react-hook-form' -import { Button } from '@/components/ui/Button' -import { Modal } from '@/components/ui/Modal' -import { Badge } from '@/components/ui/Badge' -import { useToast } from '@/components/ui/Toast' +import { useForm } from "react-hook-form"; +import { Button } from "@/components/ui/Button"; +import { Modal } from "@/components/ui/Modal"; +import { Badge } from "@/components/ui/Badge"; +import { useToast } from "@/components/ui/Toast"; import { ConfirmModal } from "@/components/ui/ConfirmModal"; interface Assignment { - _id: string - title: string - description: string - subject: string - class: string - deadline: string - status: 'active' | 'closed' - kanbanStatus: 'todo' | 'in_progress' | 'submitted' - maxMarks: number - createdAt: string + _id: string; + title: string; + description: string; + subject: string; + class: string; + deadline: string; + status: "active" | "closed"; + kanbanStatus: "todo" | "in_progress" | "submitted"; + maxMarks: number; + createdAt: string; } interface FormData { - title: string - description: string - subject: string - class: string - deadline: string - maxMarks: number + title: string; + description: string; + subject: string; + class: string; + deadline: string; + maxMarks: number; } -type KanbanCol = 'todo' | 'in_progress' | 'submitted' +type KanbanCol = "todo" | "in_progress" | "submitted"; const COLUMNS: { id: KanbanCol; @@ -59,7 +59,9 @@ const COLUMNS: { ]; function daysUntil(deadline: string) { - return Math.ceil((new Date(deadline).getTime() - Date.now()) / (1000 * 60 * 60 * 24)) + return Math.ceil( + (new Date(deadline).getTime() - Date.now()) / (1000 * 60 * 60 * 24), + ); } function DeadlineBadge({ deadline }: { deadline: string }) { @@ -241,11 +243,11 @@ function AssignmentDrawer({ } export function AssignmentsClient() { - const { toast } = useToast() - const [assignments, setAssignments] = useState([]) - const [loading, setLoading] = useState(true) - const [modalOpen, setModalOpen] = useState(false) - const [editing, setEditing] = useState(null) + const { toast } = useToast(); + const [assignments, setAssignments] = useState([]); + const [loading, setLoading] = useState(true); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); const [drawerAssignment, setDrawerAssignment] = useState( null, ); @@ -283,8 +285,7 @@ export function AssignmentsClient() { setAssignments( raw.map((a) => ({ ...a, - kanbanStatus: - a.kanbanStatus ?? (a.status === "closed" ? "submitted" : "todo"), + kanbanStatus: a.kanbanStatus, })), ); } catch (error) { @@ -359,7 +360,11 @@ export function AssignmentsClient() { const res = await fetch(url, { method, headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...data, maxMarks: Number(data.maxMarks) }), + body: JSON.stringify({ + ...data, + deadline: new Date(data.deadline).toISOString(), + maxMarks: Number(data.maxMarks), + }), }); if (res.ok) { toast( @@ -464,10 +469,16 @@ export function AssignmentsClient() { return (
{COLUMNS.map((col) => ( -
+
{[1, 2].map((i) => ( -
+
@@ -475,7 +486,7 @@ export function AssignmentsClient() {
))}
- ) + ); } return ( diff --git a/app/dashboard/profile/ProfileClient.tsx b/app/dashboard/profile/ProfileClient.tsx index 71e235e..fa8c236 100644 --- a/app/dashboard/profile/ProfileClient.tsx +++ b/app/dashboard/profile/ProfileClient.tsx @@ -1,107 +1,129 @@ -'use client' +"use client"; -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from "react"; import Image from "next/image"; -import { useForm } from 'react-hook-form' -import { useUser } from '@clerk/nextjs' -import { Button } from '@/components/ui/Button' -import { useToast } from '@/components/ui/Toast' -import { CardSkeleton } from '@/components/ui/Skeleton' +import { useForm } from "react-hook-form"; +import { useUser } from "@clerk/nextjs"; +import { Button } from "@/components/ui/Button"; +import { useToast } from "@/components/ui/Toast"; +import { CardSkeleton } from "@/components/ui/Skeleton"; import { ConfirmModal } from "@/components/ui/ConfirmModal"; +import { formatZodError } from "@/lib/utils"; interface AcademicHistoryEntry { - year: string - title: string - description?: string + year: string; + title: string; + description?: string; } interface TeacherProfile { - name: string - email: string - department: string - subjects: string[] - phone: string - bio: string - academicHistory: AcademicHistoryEntry[] - createdAt?: string + name: string; + email: string; + department: string; + subjects: string[]; + phone: string; + bio: string; + academicHistory: AcademicHistoryEntry[]; + createdAt?: string; } interface FormData { - name: string - department: string - subjectsRaw: string - phone: string - bio: string + name: string; + department: string; + subjectsRaw: string; + phone: string; + bio: string; } interface TimelineFormData { - year: string - title: string - description: string + year: string; + title: string; + description: string; } export function ProfileClient() { - const { user } = useUser() - const { toast } = useToast() - const [loading, setLoading] = useState(true) - const [profile, setProfile] = useState(null) - const [editing, setEditing] = useState(false) - const [avatarUploading, setAvatarUploading] = useState(false) - const [timelineModalOpen, setTimelineModalOpen] = useState(false) - const [editingEntry, setEditingEntry] = useState<{ index: number; entry: AcademicHistoryEntry } | null>(null) + const { user } = useUser(); + const { toast } = useToast(); + const [loading, setLoading] = useState(true); + const [profile, setProfile] = useState(null); + const [editing, setEditing] = useState(false); + const [savingHistory, setSavingHistory] = useState(false); + const [avatarUploading, setAvatarUploading] = useState(false); + const [timelineModalOpen, setTimelineModalOpen] = useState(false); + const [editingEntry, setEditingEntry] = useState<{ + index: number; + entry: AcademicHistoryEntry; + } | null>(null); const [deleteEntryIndex, setDeleteEntryIndex] = useState(null); - const avatarInputRef = useRef(null) + const avatarInputRef = useRef(null); - const { register, handleSubmit, reset, formState: { isSubmitting } } = useForm() + const { + register, + handleSubmit, + reset, + formState: { isSubmitting }, + } = useForm(); const { register: tlRegister, handleSubmit: tlHandleSubmit, reset: tlReset, formState: { isSubmitting: tlSubmitting, errors: tlErrors }, - } = useForm() + } = useForm(); useEffect(() => { async function load() { - setLoading(true) + setLoading(true); try { - const res = await fetch('/api/profile') - if (!res.ok) throw new Error(`Failed to load profile: ${res.status}`) - const data = await res.json() - setProfile(data) + const res = await fetch("/api/profile"); + if (!res.ok) throw new Error(`Failed to load profile: ${res.status}`); + const data = await res.json(); + setProfile(data); reset({ name: data.name, department: data.department, - subjectsRaw: (data.subjects ?? []).join(', '), + subjectsRaw: (data.subjects ?? []).join(", "), phone: data.phone, bio: data.bio, - }) + }); } catch (err) { - toast(err instanceof Error ? err.message : 'Failed to load profile', 'error') + toast( + err instanceof Error ? err.message : "Failed to load profile", + "error", + ); } finally { - setLoading(false) + setLoading(false); } } - load() - }, [reset, toast]) + load(); + }, [reset, toast]); // ── Avatar upload via Clerk ── const handleAvatarChange = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file || !user) return - if (!file.type.startsWith('image/')) { toast('Please select an image file', 'error'); return } - if (file.size > 5 * 1024 * 1024) { toast('Image must be under 5 MB', 'error'); return } + const file = e.target.files?.[0]; + if (!file || !user) return; + if (!file.type.startsWith("image/")) { + toast("Please select an image file", "error"); + return; + } + if (file.size > 5 * 1024 * 1024) { + toast("Image must be under 5 MB", "error"); + return; + } - setAvatarUploading(true) + setAvatarUploading(true); try { - await user.setProfileImage({ file }) - toast('Avatar updated!', 'success') + await user.setProfileImage({ file }); + toast("Avatar updated!", "success"); } catch (err) { - toast(err instanceof Error ? err.message : 'Failed to upload avatar', 'error') + toast( + err instanceof Error ? err.message : "Failed to upload avatar", + "error", + ); } finally { - setAvatarUploading(false) - e.target.value = '' + setAvatarUploading(false); + e.target.value = ""; } - } + }; // ── Profile form submit ── const onSubmit = async (data: FormData) => { @@ -111,27 +133,32 @@ export function ProfileClient() { .map((s) => s.trim()) .filter(Boolean); const subjects = Array.from(new Set(subjectsArray)); + const payload: any = {}; + + if (data.name.trim()) payload.name = data.name.trim(); + if (data.department.trim()) payload.department = data.department.trim(); + if (subjects.length) payload.subjects = subjects; + if (data.phone.trim()) payload.phone = data.phone.trim(); + if (data.bio.trim()) payload.bio = data.bio.trim(); + try { const res = await fetch("/api/profile", { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: data.name, - department: data.department, - subjects, - phone: data.phone, - bio: data.bio, - academicHistory: profile?.academicHistory ?? [], - }), + body: JSON.stringify(payload), }); - if (res.ok) { - const updated = await res.json(); - setProfile(updated); - toast("Profile updated!", "success"); - setEditing(false); - } else { - toast("Failed to update profile", "error"); + const result = await res.json(); + if (!res.ok) { + const submitErrors = formatZodError(result.error); + submitErrors.forEach((error) => { + toast(error, "error"); + }); + return; } + + setProfile(result); + toast("Profile updated!", "success"); + setEditing(false); } catch (error) { toast( `Network error: ${error instanceof Error ? error.message : "Failed to update profile"}`, @@ -150,7 +177,6 @@ export function ProfileClient() { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - ...profile, academicHistory: history, }), }); @@ -173,32 +199,42 @@ export function ProfileClient() { }; const openAddEntry = () => { - setEditingEntry(null) - tlReset({ year: String(new Date().getFullYear()), title: '', description: '' }) - setTimelineModalOpen(true) - } + setEditingEntry(null); + tlReset({ + year: String(new Date().getFullYear()), + title: "", + description: "", + }); + setTimelineModalOpen(true); + }; const openEditEntry = (index: number, entry: AcademicHistoryEntry) => { - setEditingEntry({ index, entry }) - tlReset({ year: entry.year, title: entry.title, description: entry.description ?? '' }) - setTimelineModalOpen(true) - } + setEditingEntry({ index, entry }); + tlReset({ + year: entry.year, + title: entry.title, + description: entry.description ?? "", + }); + setTimelineModalOpen(true); + }; const onTimelineSubmit = async (data: TimelineFormData) => { - const current = [...(profile?.academicHistory ?? [])] + const current = [...(profile?.academicHistory ?? [])]; if (editingEntry !== null) { - current[editingEntry.index] = data + current[editingEntry.index] = data; } else { - current.push(data) + current.push(data); } // sort descending by year - current.sort((a, b) => b.year.localeCompare(a.year)) + current.sort((a, b) => Number(b.year) - Number(a.year)); + setSavingHistory(true); const success = await saveHistory(current); + setSavingHistory(false); if (success) { toast("Academic history saved!", "success"); setTimelineModalOpen(false); } - } + }; const executeDeleteEntry = async () => { if (deleteEntryIndex === null) return; @@ -216,7 +252,7 @@ export function ProfileClient() {
- ) + ); } return ( @@ -395,7 +431,9 @@ export function ProfileClient() { Subjects (comma-separated) val.trim().length > 0 || "Required", + })} className="input w-full" placeholder="Mathematics, Physics, Programming" /> @@ -469,7 +507,12 @@ export function ProfileClient() {

Academic History

-