diff --git a/client/package-lock.json b/client/package-lock.json index 2bbf7097..419a7af7 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -46,6 +46,9 @@ }, "devDependencies": { "@types/date-and-time": "^0.13.0", + "@types/dompurify": "^3.0.0", + "@types/js-cookie": "^3.0.3", + "@types/marked": "^4.0.8", "@types/node": "^18.14.0", "@types/react": "^18.0.28", "@types/react-dom": "^18.0.11", @@ -1236,6 +1239,15 @@ "integrity": "sha512-kHEncapIgrqaY8r2tyb19EvdKyhNjwheLl5cYTorsWJtURoI+oGm5ehW8CLAaq4dvu8x9z56FcXqAT4Mm5Nvzw==", "dev": true }, + "node_modules/@types/dompurify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.0.tgz", + "integrity": "sha512-EcSqmgm/xJwH8CcJPy9AHNypp/j58CYga3nWdl93/wLxX6OH+rSD3aAj75NQazcZd1YKHJ/pjNZ9qmgVajggwQ==", + "dev": true, + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/history": { "version": "4.7.11", "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", @@ -1310,6 +1322,18 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" }, + "node_modules/@types/js-cookie": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.3.tgz", + "integrity": "sha512-Xe7IImK09HP1sv2M/aI+48a20VX+TdRJucfq4vfRVy6nWN8PYPOEnlMRSgxJAgYQIXJVL8dZ4/ilAM7dWNaOww==", + "dev": true + }, + "node_modules/@types/marked": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@types/marked/-/marked-4.0.8.tgz", + "integrity": "sha512-HVNzMT5QlWCOdeuBsgXP8EZzKUf0+AXzN+sLmjvaB3ZlLqO+e4u0uXrdw9ub69wBKFs+c6/pA4r9sy6cCDvImw==", + "dev": true + }, "node_modules/@types/node": { "version": "18.14.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.0.tgz", @@ -1394,6 +1418,12 @@ "@types/jest": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz", + "integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==", + "dev": true + }, "node_modules/@types/warning": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.0.tgz", @@ -4799,6 +4829,15 @@ "integrity": "sha512-kHEncapIgrqaY8r2tyb19EvdKyhNjwheLl5cYTorsWJtURoI+oGm5ehW8CLAaq4dvu8x9z56FcXqAT4Mm5Nvzw==", "dev": true }, + "@types/dompurify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.0.tgz", + "integrity": "sha512-EcSqmgm/xJwH8CcJPy9AHNypp/j58CYga3nWdl93/wLxX6OH+rSD3aAj75NQazcZd1YKHJ/pjNZ9qmgVajggwQ==", + "dev": true, + "requires": { + "@types/trusted-types": "*" + } + }, "@types/history": { "version": "4.7.11", "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", @@ -4866,6 +4905,18 @@ } } }, + "@types/js-cookie": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.3.tgz", + "integrity": "sha512-Xe7IImK09HP1sv2M/aI+48a20VX+TdRJucfq4vfRVy6nWN8PYPOEnlMRSgxJAgYQIXJVL8dZ4/ilAM7dWNaOww==", + "dev": true + }, + "@types/marked": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@types/marked/-/marked-4.0.8.tgz", + "integrity": "sha512-HVNzMT5QlWCOdeuBsgXP8EZzKUf0+AXzN+sLmjvaB3ZlLqO+e4u0uXrdw9ub69wBKFs+c6/pA4r9sy6cCDvImw==", + "dev": true + }, "@types/node": { "version": "18.14.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.0.tgz", @@ -4950,6 +5001,12 @@ "@types/jest": "*" } }, + "@types/trusted-types": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz", + "integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==", + "dev": true + }, "@types/warning": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.0.tgz", diff --git a/client/package.json b/client/package.json index 320ed5b8..960c519b 100644 --- a/client/package.json +++ b/client/package.json @@ -66,6 +66,9 @@ }, "devDependencies": { "@types/date-and-time": "^0.13.0", + "@types/dompurify": "^3.0.0", + "@types/js-cookie": "^3.0.3", + "@types/marked": "^4.0.8", "@types/node": "^18.14.0", "@types/react": "^18.0.28", "@types/react-dom": "^18.0.11", diff --git a/client/src/components/projects/ProjectCard/index.jsx b/client/src/components/projects/ProjectCard/index.jsx deleted file mode 100644 index 87b9c6ab..00000000 --- a/client/src/components/projects/ProjectCard/index.jsx +++ /dev/null @@ -1,102 +0,0 @@ -import React, { useEffect } from 'react'; -import { Link } from 'react-router-dom'; -import PropTypes from 'prop-types'; -import { Card, Popup, Icon, Button } from 'semantic-ui-react'; -import date from 'date-and-time'; -import ordinal from 'date-and-time/plugin/ordinal'; -import { truncateString } from '../../util/HelperFunctions'; -import ProjectProgressBar from '../ProjectProgressBar'; -import './ProjectCard.css'; - -/** - * A UI component representing a Project and displaying its basic information and status. - */ -const ProjectCard = ({ project, showPinButton, onPin, ...props }) => { - - useEffect(() => { - date.plugin(ordinal); - }, []); - - const projectUpdated = new Date(project.updatedAt); - const updateDate = date.format(projectUpdated, 'MM/DD/YY'); - const updateTime = date.format(projectUpdated, 'h:mm A'); - - /** - * Activates the provided callback (if valid) when the user clicks the "Pin" button. - */ - const handlePinClick = () => { - if (typeof (onPin) === 'function') { - onPin(project.projectID); - } - }; - - return ( - -
-
-
- - {truncateString(project.title, 100)} - - - Last updated {updateDate} at {updateTime} - -
-
- {showPinButton && ( - Add to your Pinned Projects} - trigger={ - - } - position='top center' - /> - )} -
-
-
-
- -
-
- -
-
- -
-
-
-
- ) -}; - -ProjectCard.propTypes = { - project: PropTypes.shape({ - projectID: PropTypes.string.isRequired, - title: PropTypes.string.isRequired, - updatedAt: PropTypes.string.isRequired, - currentProgress: PropTypes.number, - peerProgress: PropTypes.number, - a11yProgress: PropTypes.number, - showPinButton: PropTypes.bool, - onPin: PropTypes.func, - }).isRequired, -}; - -ProjectCard.defaultProps = { - showPinButton: false, -}; - -export default ProjectCard; diff --git a/client/src/components/projects/ProjectCard/index.tsx b/client/src/components/projects/ProjectCard/index.tsx new file mode 100644 index 00000000..291d1e2e --- /dev/null +++ b/client/src/components/projects/ProjectCard/index.tsx @@ -0,0 +1,106 @@ +import React, { useEffect } from "react"; +import { Link } from "react-router-dom"; +import PropTypes from "prop-types"; +import { Card, Popup, Icon, Button } from "semantic-ui-react"; +import { format, parseISO } from "date-fns"; +import { truncateString } from "../../util/HelperFunctions"; +import ProjectProgressBar from "../ProjectProgressBar"; +import "./ProjectCard.css"; +import { Project } from "../../../types"; + +type ProjectCardProps = { + project: Project; + showPinButton?: boolean; + onPin?: (projectID: string) => void; +}; +/** + * A UI component representing a Project and displaying its basic information and status. + */ +const ProjectCard = ({ + project, + showPinButton = false, + onPin = () => {}, + ...props +}: ProjectCardProps) => { + let updateDate; + let updateTime; + + if (project.updatedAt) { + const parsedDate = parseISO(project.updatedAt); + updateDate = format(parsedDate, "MM/dd/yy"); + updateTime = format(parsedDate, "h:mm aa"); + } + + /** + * Activates the provided callback (if valid) when the user clicks the "Pin" button. + */ + const handlePinClick = () => { + if (typeof onPin === "function") { + onPin(project.projectID); + } + }; + + return ( + +
+
+
+ + {truncateString(project.title, 100)} + + + Last updated {updateDate} at {updateTime} + +
+
+ {showPinButton && ( + Add to your Pinned Projects} + trigger={ + + } + position="top center" + /> + )} +
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+ ); +}; + +export default ProjectCard; diff --git a/client/src/screens/conductor/Home/Home.css b/client/src/screens/conductor/Home/Home.css index 4a78f207..326c87e1 100644 --- a/client/src/screens/conductor/Home/Home.css +++ b/client/src/screens/conductor/Home/Home.css @@ -17,6 +17,10 @@ margin: 0 0 0 0.5em !important; } +.home-unverified-label { + cursor: pointer; +} + /* Announcements */ .announcement:hover { diff --git a/client/src/screens/conductor/Home/index.jsx b/client/src/screens/conductor/Home/index.jsx deleted file mode 100644 index 548cc2e8..00000000 --- a/client/src/screens/conductor/Home/index.jsx +++ /dev/null @@ -1,996 +0,0 @@ -import './Home.css'; - -import { - Grid, - Header, - Menu, - Image, - Segment, - Message, - Icon, - Button, - Modal, - Form, - Loader, - Card, - Popup, - Dropdown, - List, - Divider -} from 'semantic-ui-react'; -import { Link } from 'react-router-dom'; -import React, { useEffect, useState, useCallback } from 'react'; -import { useSelector } from 'react-redux'; -import axios from 'axios'; -import date from 'date-and-time'; -import ordinal from 'date-and-time/plugin/ordinal'; -import queryString from 'query-string'; -import DOMPurify from 'dompurify'; -import { marked } from 'marked'; - -import ProjectCard from '../../../components/projects/ProjectCard'; -import Breakpoint from '../../../components/util/Breakpoints.tsx'; -import TextArea from '../../../components/TextArea'; - -import { - truncateString, - capitalizeFirstLetter, - isEmptyString -} from '../../../components/util/HelperFunctions.js'; -import useGlobalError from '../../../components/error/ErrorHooks.js'; - -const Home = (props) => { - - const { handleGlobalError } = useGlobalError(); - const user = useSelector((state) => state.user); - const org = useSelector((state) => state.org); - - /* Data */ - const [announcements, setAnnouncements] = useState([]); - const [recentProjects, setRecentProjects] = useState([]); - const [pinnedProjects, setPinnedProjects] = useState([]); - - /* UI */ - const [loadedAllAnnouncements, setLoadedAllAnnouncements] = useState(false); - const [loadedAllRecents, setLoadedAllRecents] = useState(false); - const [loadedAllPinned, setLoadedAllPinned] = useState(false); - const [showNASuccess, setShowNASuccess] = useState(false); - - // System Announcement Message - const [showSystemAnnouncement, setShowSystemAnnouncement] = useState(false); - const [systemAnnouncementData, setSystemAnnouncementData] = useState({}); - - // New Announcement Modal - const [showNAModal, setShowNAModal] = useState(false); - const [naTitle, setNATitle] = useState(''); - const [naMessage, setNAMessage] = useState(''); - const [naGlobal, setNAGlobal] = useState(false); - const [naTitleError, setNATitleError] = useState(false); - const [naMessageError, setNAMessageError] = useState(false); - - // Announcement View Modal - const [showAVModal, setShowAVModal] = useState(false); - const [avAnnouncement, setAVAnnouncement] = useState({}); - const [avModalLoading, setAVModalLoading] = useState(false); - - // New Member Modal - const [showNMModal, setShowNMModal] = useState(false); - - // Edit Pinned Projects Modal - const [showPinnedModal, setShowPinnedModal] = useState(false); - const [pinProjectsOptions, setPinProjectsOptions] = useState([]); - const [pinProjectToPin, setPinProjectToPin] = useState(''); - const [pinProjectsLoading, setPinProjectsLoading] = useState(false); - const [pinModalLoading, setPinModalLoading] = useState(false); - - /** - * Check for query string values and update UI if necessary. - */ - useEffect(() => { - const queryValues = queryString.parse(props.location.search); - if (queryValues.newmember === 'true') { - setShowNMModal(true); - } - }, [props.location.search, setShowNMModal]); - - - /** - * Checks if a System Announcement is available and updates the UI accordingly if so. - */ - const getSystemAnnouncement = useCallback(() => { - axios.get('/announcements/system').then((res) => { - if (!res.data.err) { - if (res.data.sysAnnouncement !== null) { - setShowSystemAnnouncement(true); - setSystemAnnouncementData(res.data.sysAnnouncement); - } - } else { - console.error(res.data.errMsg); // fail silently - } - }).catch((err) => { - console.error(err); // fail silently - }); - }, [setShowSystemAnnouncement, setSystemAnnouncementData]); - - - /** - * Loads the 5 most recent announcements via GET - * request and updates the UI accordingly. - */ - const getAnnouncements = useCallback(() => { - axios.get('/announcements/all').then((res) => { - if (!res.data.err) { - if (res.data.announcements && Array.isArray(res.data.announcements)) { - var announcementsForState = []; - res.data.announcements.forEach((item) => { - const { date, time } = parseDateAndTime(item.createdAt); - const newAnnouncement = { - ...item, - date: date, - time: time, - rawDate: item.createdAt - }; - announcementsForState.push(newAnnouncement); - }); - announcementsForState.sort((a, b) => { - const date1 = new Date(a.rawDate); - const date2 = new Date(b.rawDate); - return date2 - date1; - }); - setAnnouncements(announcementsForState); - } - } else { - handleGlobalError(res.data.errMsg); - } - setLoadedAllAnnouncements(true); - }).catch((err) => { - handleGlobalError(err); - setLoadedAllAnnouncements(true); - }); - }, [setAnnouncements, setLoadedAllAnnouncements, handleGlobalError]); - - /** - * Load the user's recent projects and update the UI accordingly. - */ - const getRecentProjects = useCallback(() => { - axios.get('/projects/recent').then((res) => { - if (!res.data.err) { - if (res.data.projects && Array.isArray(res.data.projects)) { - setRecentProjects(res.data.projects); - } - } else { - handleGlobalError(res.data.errMsg); - } - setLoadedAllRecents(true); - }).catch((err) => { - handleGlobalError(err); - setLoadedAllRecents(true); - }); - }, [setRecentProjects, setLoadedAllRecents, handleGlobalError]); - - /** - * Load the users's pinned projects and update the UI accordingly. - */ - const getPinnedProjects = useCallback(() => { - setLoadedAllPinned(false); - axios.get('/projects/pinned').then((res) => { - if (!res.data?.err) { - if (Array.isArray(res.data.projects)) { - setPinnedProjects(res.data.projects); - } - } else { - handleGlobalError(res.data.errMsg); - } - setLoadedAllPinned(true); - }).catch((err) => { - handleGlobalError(err); - setLoadedAllPinned(true); - }); - }, [setPinnedProjects, setLoadedAllPinned, handleGlobalError]); - - /** - * Setup page & title on load and - * load recent data. - */ - useEffect(() => { - document.title = "LibreTexts Conductor | Home"; - date.plugin(ordinal); - // Hook to force message links to open in new window - DOMPurify.addHook('afterSanitizeAttributes', function (node) { - if ('target' in node) { - node.setAttribute('target', '_blank'); - node.setAttribute('rel', 'noopener noreferrer') - } - }); - getPinnedProjects(); - getRecentProjects(); - getSystemAnnouncement(); - getAnnouncements(); - }, [getPinnedProjects, getRecentProjects, getSystemAnnouncement, getAnnouncements]); - - /** - * Accepts a standard ISO 8601 date or date-string - * and parses the date and time to human-readable format. - * @param {String|Date} dateInput - the date to parse - * @returns {Object} object with formatted date and time - */ - const parseDateAndTime = (dateInput) => { - const dateInstance = new Date(dateInput); - return { - date: date.format(dateInstance, 'MM/DD/YY'), - time: date.format(dateInstance, 'h:mm A') - } - }; - - /** - * Open the New Announcement modal and ensure - * all form fields are reset to their defaults. - */ - const openNAModal = () => { - resetNAForm(); - setNATitle(''); - setNAMessage(''); - setNAGlobal(false); - setShowNAModal(true); - }; - - /** - * Close the New Announcement modal and - * reset the form. - */ - const closeNAModal = () => { - setShowNAModal(false); - resetNAForm(); - setNATitle(''); - setNAMessage(''); - setNAGlobal(false); - }; - - /** - * Reset all New Announcement form - * error states - */ - const resetNAForm = () => { - setNATitleError(false); - setNAMessageError(false); - }; - - /** - * Validate the New Announcement form data, - * return 'false' if validation errors - * exists, 'true' otherwise - */ - const validateNAForm = () => { - var validForm = true; - if (isEmptyString(naTitle)) { - validForm = false; - setNATitleError(true); - } - if (isEmptyString(naMessage)) { - validForm = false; - setNAMessageError(true); - } - return validForm; - }; - - /** - * Submit data via POST to the server, then - * call closeNAModal() on success - * and reload announcements. - */ - const postNewAnnouncement = () => { - resetNAForm(); - if (validateNAForm()) { - axios.post('/announcement', { - title: naTitle, - message: naMessage, - global: naGlobal - }).then((res) => { - if (!res.data.err) { - setShowNASuccess(true); - closeNAModal(); - getAnnouncements(); - } else { - throw (res.data.errMsg); - } - }).catch((err) => { - handleGlobalError(err); - }); - } - }; - - /** - * Open the Announcement View modal - * and bring the request announcement - * into state. - */ - const openAVModal = (idx) => { - if (announcements[idx] !== undefined) { - setAVAnnouncement(announcements[idx]); - setAVModalLoading(false); - setShowAVModal(true); - } - }; - - /** - * Close the Announcement View modal - * and reset state to the empty announcement. - */ - const closeAVModal = () => { - setShowAVModal(false); - setAVAnnouncement({}); - setAVModalLoading(false); - }; - - /** - * Loads the user's projects from the server, then filters already-pinned projects before - * saving the list to state. - */ - const getPinnableProjects = useCallback(() => { - setPinProjectsLoading(true); - axios.get('/projects/all').then((res) => { - if (!res.data.err) { - if (Array.isArray(res.data.projects)) { - const pinnedFiltered = res.data.projects.filter((item) => { - const foundMatch = pinnedProjects.find((pinned) => { - return pinned.projectID === item.projectID; - }); - if (foundMatch) { - return false; - } - return true; - }).sort((a, b) => { - let normalA = String(a.title).toLowerCase().replace(/[^A-Za-z]+/g, ""); - let normalB = String(b.title).toLowerCase().replace(/[^A-Za-z]+/g, ""); - if (normalA < normalB) return -1; - if (normalA > normalB) return 1; - return 0; - }).map((item) => { - return { - key: item.projectID, - value: item.projectID, - text: item.title, - } - }); - setPinProjectsOptions(pinnedFiltered); - } - } else { - handleGlobalError(res.data.errMsg); - } - setPinProjectsLoading(false); - }).catch((err) => { - handleGlobalError(err); - setPinProjectsLoading(false); - }); - }, [pinnedProjects, setPinProjectsLoading, setPinProjectsOptions, handleGlobalError]); - - /** - * Updates the list of pinnable projects when the Edit Pinned Projects Modal is opened, - * or when there is a change in the list of pinned projects. - */ - useEffect(() => { - if (showPinnedModal) { - getPinnableProjects(); - } - }, [pinnedProjects, getPinnableProjects, showPinnedModal]); - - /** - * Opens the Edit Pinned Projects modal. - */ - const openPinnedModal = () => { - setShowPinnedModal(true); - }; - - /** - * Closes the Edit Pinned Projects modal. - */ - const closePinnedModal = () => { - setShowPinnedModal(false); - setPinProjectsOptions([]); - setPinProjectsLoading(false); - setPinModalLoading(false); - }; - - /** - * Submits a request to the server to pin a project. Refreshes the - * Pinned & Recent projects lists on success. - * - * @param {string} projectID - Identifier of the project to pin. - * @returns {Promise} True if successfully pinned, false otherwise. - */ - async function pinProject(projectID) { - if (!projectID || isEmptyString(projectID)) { - return false; - } - try { - const pinRes = await axios.put('/project/pin', { - projectID, - }); - if (!pinRes.data.err) { - getPinnedProjects(); - getRecentProjects(); - return true; - } else { - throw (new Error(pinRes.data.errMsg)); - } - } catch (e) { - handleGlobalError(e); - } - return false; - }; - - /** - * Wraps the project pinning function for use in the Edit Pinned Projects modal. Project - * selection in the modal is reset if the operation was successful. - * - * @see {@link pinProject} - */ - async function pinProjectInModal() { - if (isEmptyString(pinProjectToPin)) { - return; - } - setPinModalLoading(true); - const didPin = await pinProject(pinProjectToPin); - if (didPin) { - setPinProjectToPin(''); - setPinProjectsOptions([]); - } - setPinModalLoading(false); - } - - /** - * Submits a request to the server to unpin a project, then refreshes the pinned list. - * For use inside the Edit Pinned Projects modal. - * - * @param {string} projectID - The identifier of the project to unpin. - */ - const unpinProject = (projectID) => { - if (isEmptyString(projectID)) { - return; - } - setPinModalLoading(true); - axios.delete('/project/pin', { - data: { - projectID, - } - }).then((res) => { - if (!res.data.err) { - getPinnedProjects(); - } else { - handleGlobalError(res.data.errMsg); - } - setPinModalLoading(false); - }).catch((err) => { - handleGlobalError(err); - setPinModalLoading(false); - }); - }; - - /** - * Submit a DELETE request to the server to delete the announcement - * currently open in the Announcement View Modal, then close - * the modal and reload announcements on success. - */ - const deleteAnnouncement = () => { - if (avAnnouncement._id && avAnnouncement._id !== '') { - setAVModalLoading(true); - axios.delete('/announcement', { - data: { - announcementID: avAnnouncement._id - } - }).then((res) => { - if (!res.data.err) { - closeAVModal(); - getAnnouncements(); - } else { - handleGlobalError(res.data.errMsg); - setAVModalLoading(false); - } - }).catch((err) => { - handleGlobalError(err); - setAVModalLoading(false); - }); - } - }; - - return ( - - - -
Home
-
-
- - {showSystemAnnouncement && ( - - - - - {systemAnnouncementData.title} -

{systemAnnouncementData.message}

-
-
- -
- )} - - - - -
- -
- Welcome,
- {user.firstName} -
-
- {(user.isSuperAdmin || user.isCampusAdmin) && ( - - Control Panel - - - )} - - - My Alerts - - - Harvesting Request - - - - Adoption Report - - - - Account Request - - - - LibreTexts.org - - -
-
- - -
- -
Welcome, {user.firstName}
-
- - - {((user.hasOwnProperty('isSuperAdmin') && user.isSuperAdmin === true) || - (user.hasOwnProperty('isCampusAdmin') && user.isCampusAdmin === true)) && - - - Control Panel - - } - - - Commons - - - - My Alerts - - - - Harvesting Request - - - - Adoption Report - - - - Account Request - - - - LibreTexts.org - - - -
-
-
-
-
- - 0} loading={!loadedAllPinned}> -
0 ? 'dividing-header-custom' : 'header-custom'}> -

- - Pinned Projects -

-
- Edit Pinned Projects} - trigger={ - - } - position='top center' - /> -
-
- {(pinnedProjects.length > 0) && ( - - - {pinnedProjects.map((item) => )} - - - )} -
- -
-

- - Recently Edited Projects -

-
- To see all of your projects, visit Projects in the Navbar.} - trigger={ - - } - position='top center' - /> -
-
- - - {(recentProjects.length > 0) && - recentProjects.map((item) => ( - - )) - } - {(recentProjects.length === 0) && -

You don't have any projects right now.

- } -
-
-
-
- - -
-

Announcements (most recent)

- {(user.isCampusAdmin === true || user.isSuperAdmin === true) && -
- - - - } - position='top center' - /> -
- } -
- {showNASuccess && - setShowNASuccess(false)} - header='Announcement Successfully Posted!' - icon='check circle outline' - positive - /> - } -
- - {announcements.map((item, index) => { - return ( -
openAVModal(index)}> -
-
- -
-
- {item.title} - {item.author.firstName} {item.author.lastName} • {item.date} at {item.time} -
-
-

-
- ); - })} - {(loadedAllAnnouncements && announcements.length === 0) && -

No recent announcements.

- } -
-
-
-
- {/* New Announcement Modal */} - - New Announcement - -
- - - setNATitle(e.target.value)} - value={naTitle} - /> - - - -