Spaces:
Sleeping
Sleeping
| "use client"; | |
| import { useState, useEffect, useRef } from "react"; | |
| import axios from "axios"; | |
| import { useTheme } from "@/context/ThemeContext"; | |
| import { | |
| FiPlus, | |
| FiFolder, | |
| FiChevronDown, | |
| FiChevronUp, | |
| FiLoader, | |
| FiInbox, | |
| FiLayers, | |
| FiCheckCircle, | |
| FiAlertCircle, | |
| FiX, | |
| } from "react-icons/fi"; | |
| import AddSubjectForm from "./AddSubjectForm"; | |
| import SubjectsGrid from "./SubjectsGrid"; | |
| import Portal from "./components/Portal"; | |
| import "./styles/ManageSubjects.css"; | |
| import LoginRequired from "../components/LoginRequired"; | |
| import ManageSubjectsSkeleton from "./ManageSubjectsSkeleton"; | |
| /* βββββββββββββββββββββββββββββββββββββββββ | |
| Toast β rendered via Portal into body | |
| so no parent transform/overflow can trap it | |
| βββββββββββββββββββββββββββββββββββββββββ */ | |
| function Toast({ toasts, onDismiss }) { | |
| return ( | |
| <Portal> | |
| <div className="ms-toast-container" aria-live="polite"> | |
| {toasts.map((t) => ( | |
| <div key={t.id} className={`ms-toast ms-toast--${t.type}`} role="alert"> | |
| <span className="ms-toast-icon"> | |
| {t.type === "success" ? <FiCheckCircle /> : <FiAlertCircle />} | |
| </span> | |
| <span className="ms-toast-text">{t.text}</span> | |
| <button | |
| className="ms-toast-close" | |
| onClick={() => onDismiss(t.id)} | |
| aria-label="Dismiss" | |
| > | |
| <FiX /> | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| </Portal> | |
| ); | |
| } | |
| let _toastId = 0; | |
| /* βββββββββββββββββββββββββββββββββββββββββ | |
| Main Component | |
| βββββββββββββββββββββββββββββββββββββββββ */ | |
| export default function ManageSubjects() { | |
| const [usn, setUsn] = useState(""); | |
| const [subjects, setSubjects] = useState([]); | |
| const [page, setPage] = useState(1); | |
| const [limit] = useState(10); | |
| const [hasMore, setHasMore] = useState(false); | |
| const [loadingMore, setLoadingMore] = useState(false); | |
| const [toasts, setToasts] = useState([]); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [allUsers, setAllUsers] = useState([]); | |
| const [addOpen, setAddOpen] = useState(false); | |
| const drawerRef = useRef(null); | |
| const { theme } = useTheme(); | |
| useEffect(() => { | |
| const storedUsn = localStorage.getItem("usn"); | |
| if (storedUsn) setUsn(storedUsn); | |
| fetchSubjects(storedUsn, 1); | |
| fetchAllUsers(); | |
| }, []); | |
| useEffect(() => { | |
| if (addOpen && drawerRef.current) { | |
| setTimeout( | |
| () => drawerRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" }), | |
| 260 | |
| ); | |
| } | |
| }, [addOpen]); | |
| /* ββ Toast helpers ββ */ | |
| const pushToast = (text, type = "success", duration = 3500) => { | |
| const id = ++_toastId; | |
| setToasts((prev) => [...prev, { id, text, type }]); | |
| setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), duration); | |
| }; | |
| const dismissToast = (id) => | |
| setToasts((prev) => prev.filter((t) => t.id !== id)); | |
| /* legacy signature for child components */ | |
| const showMessage = (text, type = "") => | |
| pushToast(text, type === "error" ? "error" : "success"); | |
| /* ββ API ββ */ | |
| const fetchSubjects = async (usn, pageNum = 1, append = false) => { | |
| if (pageNum === 1) setIsLoading(true); | |
| else setLoadingMore(true); | |
| try { | |
| const res = await axios.get("/api/work/get", { params: { usn, page: pageNum, limit } }); | |
| const fetched = res.data.subjects || []; | |
| const total = res.data.paging?.total || 0; | |
| if (append) setSubjects((prev) => [...prev, ...fetched]); | |
| else setSubjects(fetched); | |
| setPage(pageNum); | |
| setHasMore(pageNum * limit < total); | |
| } catch (err) { | |
| console.error(err); | |
| pushToast("Failed to fetch subjects", "error"); | |
| } finally { | |
| setIsLoading(false); | |
| setLoadingMore(false); | |
| } | |
| }; | |
| const fetchAllUsers = async () => { | |
| try { | |
| const res = await axios.get("/api/work/getall"); | |
| setAllUsers(res.data.users || []); | |
| } catch (err) { | |
| console.error("Failed to fetch all users:", err); | |
| } | |
| }; | |
| const handleSubjectDelete = () => { | |
| fetchSubjects(usn, 1); | |
| pushToast("Subject deleted successfully!"); | |
| }; | |
| const handleTopicDelete = () => { | |
| fetchSubjects(usn, 1); | |
| pushToast("Topic deleted successfully!"); | |
| }; | |
| const handleAddSubject = async (subjectName, isPublic) => { | |
| setIsLoading(true); | |
| try { | |
| await axios.post("/api/subject", { usn, subject: subjectName, public: isPublic }); | |
| fetchSubjects(usn, 1); | |
| pushToast("Subject added successfully!"); | |
| setAddOpen(false); | |
| } catch (err) { | |
| console.error(err); | |
| pushToast(err.response?.data?.error || "Error adding subject", "error"); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| const handleAddTopic = async (subject, topicName, isPublic) => { | |
| if (!subject || !topicName.trim()) return; | |
| setIsLoading(true); | |
| try { | |
| await axios.post("/api/topic", { usn, subject, topic: topicName, images: [], public: isPublic }); | |
| fetchSubjects(usn, 1); | |
| pushToast("Topic added successfully!"); | |
| } catch (err) { | |
| console.error(err); | |
| pushToast(err.response?.data?.error || "Error adding topic", "error"); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| const refreshSubjects = () => fetchSubjects(usn, 1); | |
| const usnl = typeof window !== "undefined" ? localStorage.getItem("usn") : null; | |
| if (!usnl) return <LoginRequired />; | |
| if (isLoading && subjects.length === 0) return <ManageSubjectsSkeleton />; | |
| return ( | |
| <div className={`ms-wrapper ${theme}`}> | |
| {/* Toast β portaled to body, always above everything */} | |
| <Toast toasts={toasts} onDismiss={dismissToast} /> | |
| {/* Page Header */} | |
| <header className="ms-page-header"> | |
| <div className="ms-header-accent" /> | |
| <div className="ms-header-content"> | |
| <div className="ms-header-label"> | |
| <FiLayers className="ms-header-label-icon" /> | |
| <span>Workspace</span> | |
| </div> | |
| <h1 className="ms-page-title">Manage Subjects</h1> | |
| </div> | |
| <div className="ms-header-right"> | |
| {/* <div className="ms-header-stat"> | |
| <span className="ms-stat-num">{subjects.length}</span> | |
| <span className="ms-stat-label">subjects</span> | |
| </div> */} | |
| <button | |
| className={`ms-add-toggle-btn${addOpen ? " ms-add-toggle-btn--open" : ""}`} | |
| onClick={() => setAddOpen((v) => !v)} | |
| aria-expanded={addOpen} | |
| aria-controls="ms-add-drawer" | |
| > | |
| <FiPlus className="ms-add-toggle-plus" /> | |
| <span>New Subject</span> | |
| {addOpen | |
| ? <FiChevronUp className="ms-add-toggle-caret" /> | |
| : <FiChevronDown className="ms-add-toggle-caret" />} | |
| </button> | |
| </div> | |
| </header> | |
| {/* Collapsible Add Drawer */} | |
| <div | |
| id="ms-add-drawer" | |
| className={`ms-add-drawer${addOpen ? " ms-add-drawer--open" : ""}`} | |
| aria-hidden={!addOpen} | |
| ref={drawerRef} | |
| > | |
| <div className="ms-add-drawer-inner"> | |
| <p className="ms-add-drawer-eyebrow"> | |
| <FiPlus /> Create a new subject | |
| </p> | |
| <AddSubjectForm | |
| allUsers={allUsers} | |
| isLoading={isLoading} | |
| onAddSubject={handleAddSubject} | |
| /> | |
| </div> | |
| </div> | |
| {/* Subjects & Topics */} | |
| <main className="ms-main"> | |
| <section className="ms-panel ms-panel--list"> | |
| <div className="ms-panel-header"> | |
| <div className="ms-panel-title-row"> | |
| <span className="ms-panel-icon-wrap ms-panel-icon-wrap--blue"> | |
| <FiFolder /> | |
| </span> | |
| <h2 className="ms-panel-title">Subjects & Topics</h2> | |
| </div> | |
| </div> | |
| <div className="ms-panel-body"> | |
| {isLoading && subjects.length === 0 && ( | |
| <div className="ms-state ms-state--loading"> | |
| <FiLoader className="ms-spinner-icon" /> | |
| <span>Loading subjectsβ¦</span> | |
| </div> | |
| )} | |
| {subjects.length === 0 && !isLoading && ( | |
| <div className="ms-state ms-state--empty"> | |
| <FiInbox className="ms-empty-icon" /> | |
| <p>No subjects yet. Hit <strong>New Subject</strong> above to get started.</p> | |
| </div> | |
| )} | |
| <SubjectsGrid | |
| subjects={subjects} | |
| allUsers={allUsers} | |
| usn={usn} | |
| isLoading={isLoading} | |
| onAddTopic={handleAddTopic} | |
| onSubjectDelete={handleSubjectDelete} | |
| onTopicDelete={handleTopicDelete} | |
| onRefreshSubjects={refreshSubjects} | |
| showMessage={showMessage} | |
| /> | |
| {hasMore && ( | |
| <div className="ms-load-more-wrap"> | |
| <button | |
| onClick={() => fetchSubjects(usn, page + 1, true)} | |
| disabled={loadingMore} | |
| className="ms-load-more-btn" | |
| > | |
| {loadingMore | |
| ? <><FiLoader className="ms-spinner-icon" /> Loadingβ¦</> | |
| : <><FiChevronDown /> View more subjects</>} | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| </section> | |
| </main> | |
| </div> | |
| ); | |
| } |