From c651281e1ab7b727bedd79e2c04c5e163898ba45 Mon Sep 17 00:00:00 2001 From: Akhileshwar Shriram <112577383+AkhilTheBoss@users.noreply.github.com> Date: Wed, 16 Jul 2025 16:51:04 -0700 Subject: [PATCH] style: improved spacing and layout in campus settings view --- .../controlpanel/CampusSettings.tsx | 12 +- .../OrgsManager/CampusSettingsForm.tsx | 1217 +++++++---------- 2 files changed, 517 insertions(+), 712 deletions(-) diff --git a/client/src/components/controlpanel/CampusSettings.tsx b/client/src/components/controlpanel/CampusSettings.tsx index a3b89726..0d1d3f6c 100644 --- a/client/src/components/controlpanel/CampusSettings.tsx +++ b/client/src/components/controlpanel/CampusSettings.tsx @@ -1,5 +1,5 @@ import "./ControlPanel.css"; -import { useRef, useState } from "react"; +import { useRef, useState, useEffect } from "react"; import { Link } from "react-router-dom"; import { Breadcrumb, @@ -15,6 +15,16 @@ import { useTypedSelector } from "../../state/hooks"; const CampusSettings = () => { //Global state const org = useTypedSelector((state) => state.org); + const user = useTypedSelector((state) => state.user); + + useEffect(() => { + if (!user || !user.uuid) { // Ensure user is loaded before checking roles + return; + } + if (!user.isCampusAdmin && !user.isSuperAdmin && !user.isSupport) { + window.location.href = "/home"; + } + }, [user]); const settingsFormRef = useRef>(null); diff --git a/client/src/components/controlpanel/OrgsManager/CampusSettingsForm.tsx b/client/src/components/controlpanel/OrgsManager/CampusSettingsForm.tsx index 66eedc18..fec37663 100644 --- a/client/src/components/controlpanel/OrgsManager/CampusSettingsForm.tsx +++ b/client/src/components/controlpanel/OrgsManager/CampusSettingsForm.tsx @@ -8,16 +8,16 @@ import { lazy, } from "react"; import { - Segment, Form, Button, - Message, Icon, - Divider, Popup, Table, Input, Checkbox, + Tab, + Menu, + Message, } from "semantic-ui-react"; import CtlTextInput from "../../ControlledInputs/CtlTextInput"; import { @@ -31,10 +31,11 @@ import { CampusSettingsOpts } from "../../../types"; import isHexColor from "validator/es/lib/isHexColor"; import { required } from "../../../utils/formRules"; import { useTypedSelector } from "../../../state/hooks"; -import axios from "axios"; import CommonsModuleControl from "./CommonsModuleControl"; import CampusAliasesControl from "./CampusAliasesControl"; import CtlCheckbox from "../../ControlledInputs/CtlCheckbox"; +import axios from "axios"; +import { useMediaQuery } from "react-responsive"; const CustomOrgListModal = lazy(() => import("../CustomOrgListModal")); type CampusSettingsFormProps = { @@ -53,16 +54,14 @@ const CampusSettingsForm = forwardRef( props: CampusSettingsFormProps, ref: React.ForwardedRef ) => { - // Global State and Error Handling const dispatch = useDispatch(); const org = useTypedSelector((state) => state.org); const { handleGlobalError } = useGlobalError(); const [aliases, setAliases] = useState([]); + const isTailwindLg = useMediaQuery({ minWidth: 1024 }); const { control, - register, - trigger, watch, reset: resetForm, handleSubmit, @@ -121,60 +120,42 @@ const CampusSettingsForm = forwardRef( const [smallLogoLoading, setSmallLogoLoading] = useState(false); const [smallLogoUploaded, setSmallLogoUploaded] = useState(false); - // Call handleSubmit() from parent component - useImperativeHandle(ref, () => { - return { - requestSave() { - handleSubmit(saveChanges)(); - }, - }; - }); + useImperativeHandle(ref, () => ({ + requestSave() { + handleSubmit(saveChanges)(); + }, + })); - // Dispatch necessary state changes to parent component useEffect(() => { - if (props.onUpdateLoadedData) { - props.onUpdateLoadedData(loadedData); - } - if (props.onUpdateSavedData) { - props.onUpdateSavedData(savedData); - } + if (props.onUpdateLoadedData) props.onUpdateLoadedData(loadedData); + if (props.onUpdateSavedData) props.onUpdateSavedData(savedData); }, [loadedData, savedData]); - // Update form values when matching tags change useEffect(() => { setFormValue( "catalogMatchingTags", matchingTags.map((item) => item.value), - { - shouldDirty: true, - } + { shouldDirty: true } ); }, [matchingTags]); - /** - * Retrieves Organization info via GET request from the server, then updates state. - */ const getOrganization = useCallback(async () => { try { setLoadedData(false); const res = await axios.get(`/org/${props.orgID}`); - if (res.data.err) { - throw new Error(res.data.errMsg); - } + if (res.data.err) throw new Error(res.data.errMsg); resetForm({ ...res.data, commonsModules: res.data.commonsModules ?? DEFAULT_COMMONS_MODULES, }); setAliases(res.data.aliases ?? []); - // Make local copies of matching tags with unique keys setMatchingTags( res.data.catalogMatchingTags?.map((item: string) => ({ key: crypto.randomUUID(), value: item, })) ?? [] ); - setLoadedData(true); } catch (err) { handleGlobalError(err); @@ -184,32 +165,20 @@ const CampusSettingsForm = forwardRef( } }, [props.orgID, setLoadedData, handleGlobalError]); - /** - * Set page title on initial load. - */ useEffect(() => { document.title = "LibreTexts Conductor | Campus Settings"; getOrganization(); }, [getOrganization]); - // Display a success message when data is saved useEffect(() => { - if(!savedData) return; + if (!savedData) return; setTimeout(() => setSavedData(false), 3000); - }, [savedData]) + }, [savedData]); - /** - * Validate the form, then submit - * changes (if any) via PUT request - * to the server, then re-sync - * Organization info. - */ const saveChanges = async (d: CampusSettingsOpts) => { try { setLoadedData(false); - d.aliases = aliases.filter((alias) => alias.length >= 1 && alias.length <= 100); - let primaryColorErr = false; let footerColorErr = false; @@ -218,43 +187,32 @@ const CampusSettingsForm = forwardRef( getFormValue("primaryColor") && !isHexColor(getFormValue("primaryColor")!) ) { - setFormError("primaryColor", { - message: "Not a valid hex color.", - }); + setFormError("primaryColor", { message: "Not a valid hex color." }); primaryColorErr = true; } - if ( getFormValue("footerColor") && !isHexColor(getFormValue("footerColor")!) ) { - setFormError("footerColor", { - message: "Not a valid hex color.", - }); + setFormError("footerColor", { message: "Not a valid hex color." }); footerColorErr = true; } - if (primaryColorErr || footerColorErr) { setLoadedData(true); return; } - d.primaryColor = sanitizeCustomColor( - getFormValue("primaryColor") ?? "" - ); + d.primaryColor = sanitizeCustomColor(getFormValue("primaryColor") ?? ""); d.footerColor = sanitizeCustomColor(getFormValue("footerColor") ?? ""); const saveRes = await axios.put(`/org/${props.orgID}`, d); - if (saveRes.data.err) { - throw new Error(saveRes.data.errMsg); - } + if (saveRes.data.err) throw new Error(saveRes.data.errMsg); if (saveRes.data?.updatedOrg?.orgID === org.orgID) { dispatch({ type: "SET_ORG_INFO", payload: saveRes.data.updatedOrg, }); } - setLoadedData(true); setSavedData(true); } catch (err) { @@ -264,50 +222,20 @@ const CampusSettingsForm = forwardRef( } }; - /** - * Activates the Cover Photo file input selector. - */ + // Asset upload handlers function handleUploadCoverPhoto() { - if (coverPhotoRef.current) { - (coverPhotoRef.current as HTMLInputElement).click(); - } + if (coverPhotoRef.current) (coverPhotoRef.current as HTMLInputElement).click(); } - - /** - * Activates the Large Logo file input selector. - */ function handleUploadLargeLogo() { - if (largeLogoRef.current) { - (largeLogoRef.current as HTMLInputElement).click(); - } + if (largeLogoRef.current) (largeLogoRef.current as HTMLInputElement).click(); } - - /** - * Activates the Medium Logo file input selector. - */ function handleUploadMediumLogo() { - if (mediumLogoRef.current) { - (mediumLogoRef.current as HTMLInputElement).click(); - } + if (mediumLogoRef.current) (mediumLogoRef.current as HTMLInputElement).click(); } - - /** - * Activates the Small Logo file input selector. - */ function handleUploadSmallLogo() { - if (smallLogoRef.current) { - (smallLogoRef.current as HTMLInputElement).click(); - } + if (smallLogoRef.current) (smallLogoRef.current as HTMLInputElement).click(); } - - /** - * Passes the Cover Photo file selection event to the asset uploader. - * - * @param {React.FormEvent} event - File selection event. - */ - function handleCoverPhotoFileChange( - event: React.FormEvent - ) { + function handleCoverPhotoFileChange(event: React.FormEvent) { handleAssetUpload( event, "coverPhoto", @@ -316,15 +244,7 @@ const CampusSettingsForm = forwardRef( setCoverPhotoUploaded ); } - - /** - * Passes the Large Logo file selection event to the asset uploader. - * - * @param {React.FormEvent} event - File selection event. - */ - function handleLargeLogoFileChange( - event: React.FormEvent - ) { + function handleLargeLogoFileChange(event: React.FormEvent) { handleAssetUpload( event, "largeLogo", @@ -333,15 +253,7 @@ const CampusSettingsForm = forwardRef( setLargeLogoUploaded ); } - - /** - * Passes the Medium Logo file selection event to the asset uploader. - * - * @param {React.FormEvent} event - File selection event. - */ - function handleMediumLogoFileChange( - event: React.FormEvent - ) { + function handleMediumLogoFileChange(event: React.FormEvent) { handleAssetUpload( event, "mediumLogo", @@ -350,15 +262,7 @@ const CampusSettingsForm = forwardRef( setMediumLogoUploaded ); } - - /** - * Passes the Small Logo file selection event to the asset uploader. - * - * @param {React.FormEvent} event - File selection event. - */ - function handleSmallLogoFileChange( - event: React.FormEvent - ) { + function handleSmallLogoFileChange(event: React.FormEvent) { handleAssetUpload( event, "smallLogo", @@ -367,16 +271,6 @@ const CampusSettingsForm = forwardRef( setSmallLogoUploaded ); } - - /** - * Uploads a selected asset file to the server, then updates state accordingly. - * - * @param {React.FormEvent} event - File selection event. - * @param {keyof CampusSettings} assetName - Name of the asset being uploaded/replaced. - * @param {function} assetLinkUpdater - State setter for the respective asset link. - * @param {function} uploadingStateUpdater - State setter for the respective asset upload status. - * @param {function} uploadSuccessUpdater - State setter for the respective asset upload success flag. - */ async function handleAssetUpload( event: any, assetName: keyof CampusSettingsOpts, @@ -389,14 +283,11 @@ const CampusSettingsForm = forwardRef( uploadSuccessUpdater: Function ) { const validFileTypes = ["image/jpeg", "image/png"]; - if (!event.target || typeof event?.target?.files !== "object") { - return; - } + if (!event.target || typeof event?.target?.files !== "object") return; if (event.target.files.length !== 1) { handleGlobalError("Only one file can be uploaded at a time."); return; } - const newAsset = event.target.files[0]; if ( !(newAsset instanceof File) || @@ -404,11 +295,9 @@ const CampusSettingsForm = forwardRef( ) { handleGlobalError("Sorry, that file type is not supported."); } - uploadingStateUpdater(true); const formData = new FormData(); formData.append("assetFile", newAsset); - try { const uploadRes = await axios.post( `/org/${props.orgID}/branding-images/${assetName}`, @@ -419,9 +308,7 @@ const CampusSettingsForm = forwardRef( getOrganization(); uploadSuccessUpdater(true); if (uploadRes.data.url) { - assetLinkUpdater(assetName, uploadRes.data.url, { - shouldDirty: true, - }); + assetLinkUpdater(assetName, uploadRes.data.url, { shouldDirty: true }); } } else { throw new Error(uploadRes.data.errMsg); @@ -438,22 +325,17 @@ const CampusSettingsForm = forwardRef( { key: crypto.randomUUID(), value: "" }, ]); } - function handleCatalogMatchTagEdit(e: React.ChangeEvent) { const rowID = e.target.id.split(".")[1]; setMatchingTags((prev) => prev.map((item) => { if (rowID === item.key) { - return { - ...item, - value: e.target.value, - }; + return { ...item, value: e.target.value }; } return item; }) ); } - function handleCatalogMatchTagDelete(key: string) { setMatchingTags((prev) => prev.filter((item) => item.key !== key)); } @@ -477,580 +359,493 @@ const CampusSettingsForm = forwardRef( } } - return ( - <> -
- {props.showCatalogSettings && ( - <> -

LibreGrid Settings

- - - setFormValue( - "addToLibreGridList", - !getFormValue("addToLibreGridList"), - { shouldDirty: true } - ) - } - checked={watch("addToLibreGridList")} - /> - - -

Commons Catalog Settings

- -

- {`Use Catalog Matching Tags to customize the Book results that appear in the Campus Commons catalog: - if a tag entered here is present in a Book's tags, the Book will automatically be included in the potential catalog search results.`} -

- - - - Tag - - Actions - - - - - {matchingTags.map((item, idx) => ( - - - - - - - - - -
- - - )} - -

Branding Images

- - - -

- A download link to the organization's large cover photo, - displayed on the Campus Commons jumbotron. Dimensions should be{" "} - at least 1920x1080.{" "} - Organization logos should not be used as the Cover Photo. -

- - - - - - {coverPhotoUploaded && ( - - - Campus Cover Photo successfully uploaded. - - )} -
- - -

- A download link to the organization's main/large logo. - This is typically an extended wordmark. Logo should preferably - have a transparent background. Resolution should be high enough to - avoid blurring on digital screens. -

- - - - - - {largeLogoUploaded && ( - - - Campus Large Logo successfully uploaded. - - )} -
- - -

- A download link to the organization's medium-sized logo. - This is typically a standard, non-extended wordmark. Logo should - preferably have a transparent background. Resolution should be - high enough to avoid blurring on digital screens.{" "} - - If the organization does not have distinct large/medium logos, - the same logo can be used for both. - -

- - - - - - {mediumLogoUploaded && ( - - - Campus Medium Logo successfully uploaded. - - )} -
- - -

- A download link to the organization's smallest logo. This - is typically the same style used for favicons or simplified - communications branding. Logo should preferably have a transparent - background. Dimensions should be approximately 800x800.{" "} - - The Small Logo is not currently implemented in any portion of - Commons or Conductor, but has been provisioned for possible - future customizations. - -

- - - + const panes = [ + { + menuItem: Aliases & Custom Lists, + render: () => ( + +
+
+

Campus Aliases

+

+ Add other names of your campus to help us find textbooks to display in Commons. +

+
+ +
+
+
+

Custom Org/Campus List (optional)

+

+ Customize the list of organization/campus options available in certain contexts (i.e. associating organizations with a project). This is useful for university systems or groups that have a specific set of organizations they want users to be able to select from. If no custom list is set, the default list from LibreTexts will be shown. +

- - {smallLogoUploaded && ( - - - Campus Small Logo successfully uploaded. - - )} - - - -

Branding Links

- - - - -
- -

Branding Text

- - - - - - - - -
- - -
- - - - - - - - -
- -

Branding Colors

- - - -
- Primary Color Preview -
setShowCustomOrgListModal(false)} + initCustomOrgList={watch("customOrgList")} + onSave={(newList: string[]) => { + setFormValue("customOrgList", newList, { shouldDirty: false }); + setShowCustomOrgListModal(false); }} />
- - - - -
- Footer Color Preview -
-
- - - -
-

Campus Aliases

-

- Add other names of your campus to help us find textbooks to - display in Commons. -

-
- -
-
-
- -
-

- Custom Org/Campus List (optional) -

-

- Customize the list of organization/campus options availble in - certain contexts (i.e. associating organizations with a project). - This is useful for university systems or groups that have a - specific set of organizations they want users to be able to select - from. If no custom list is set, the default list from LibreTexts - will be shown. -

-
-
- -
-

Campus Commons Catalog Modules

-

- Enable, disable, or re-order the display of Catalog modules in - your Campus Commons. -

- + + ), + }, + { + menuItem: Branding, + render: () => ( + + {/* --- Branding Images, Links, Text, Colors --- */} + +
+ {/* Branding Images */} +
+

Branding Images

+ {/* Cover Photo */} + + +

+ A download link to the organization's large cover photo, displayed on the Campus Commons jumbotron. Dimensions should be at least 1920x1080. Organization logos should not be used as the Cover Photo. +

+ + + + + + {coverPhotoUploaded && ( + + + Campus Cover Photo successfully uploaded. + + )} +
+ {/* Large Logo */} + + +

+ A download link to the organization's main/large logo. This is typically an extended wordmark. Logo should preferably have a transparent background. Resolution should be high enough to avoid blurring on digital screens. +

+ + + + + + {largeLogoUploaded && ( + + + Campus Large Logo successfully uploaded. + + )} +
+ {/* Medium Logo */} + + +

+ A download link to the organization's medium-sized logo. This is typically a standard, non-extended wordmark. Logo should preferably have a transparent background. Resolution should be high enough to avoid blurring on digital screens. If the organization does not have distinct large/medium logos, the same logo can be used for both. +

+ + + + + + {mediumLogoUploaded && ( + + + Campus Medium Logo successfully uploaded. + + )} +
+ {/* Small Logo */} + + +

+ A download link to the organization's smallest logo. This is typically the same style used for favicons or simplified communications branding. Logo should preferably have a transparent background. Dimensions should be approximately 800x800. The Small Logo is not currently implemented in any portion of Commons or Conductor, but has been provisioned for possible future customizations. +

+ + + + + + {smallLogoUploaded && ( + + + Campus Small Logo successfully uploaded. + + )} +
+
+ {/* Branding Links */} +
+

Branding Links

+ + + + +
+ {/* Branding Text */} +
+

Branding Text

+ + + + + + + + +
+ + +
+ + + + + + + + +
+ {/* Branding Colors */} +
+

Branding Colors

+ + + +
+ Primary Color Preview +
+
+ + + + +
+ Footer Color Preview +
+
+ +
+
+ + + ), + }, + { + menuItem: Campus Admins, + render: () => ( + +
+ + + Coming Soon +
- - -
-

- Disable Inherent Commons Filters -

-

- Disable the display of certain filters automatically available in - the Commons Catalog search interface. If a Catalog module is - disabled, the settings for that module here will have no effect. -

-
-

Assets

-
- handleToggleAssetFilterExclusion("fileType")} - checked={ - watch("assetFilterExclusions")?.includes("fileType") ?? - false - } - /> - handleToggleAssetFilterExclusion("org")} - checked={ - watch("assetFilterExclusions")?.includes("org") ?? false - } - className="ml-4" - /> - handleToggleAssetFilterExclusion("person")} - checked={ - watch("assetFilterExclusions")?.includes("person") ?? false - } - className="ml-4" + + ), + }, + { + menuItem: Commons Catalog Modules, + render: () => ( + +
+
+
+

Campus Commons Catalog Modules

+

+ Enable, disable, or re-order the display of Catalog modules in your Campus Commons. +

+
+
+

Disable Inherent Commons Filters

+

+ Disable the display of certain filters automatically available in the Commons Catalog search interface. If a Catalog module is disabled, the settings for that module here will have no effect. +

+
+

Assets

+
+ handleToggleAssetFilterExclusion("fileType")} + checked={ + watch("assetFilterExclusions")?.includes("fileType") ?? false + } + /> + handleToggleAssetFilterExclusion("org")} + checked={ + watch("assetFilterExclusions")?.includes("org") ?? false + } + className="ml-4" + /> + handleToggleAssetFilterExclusion("person")} + checked={ + watch("assetFilterExclusions")?.includes("person") ?? false + } + className="ml-4" + /> +
+
+
-
- - - setShowCustomOrgListModal(false)} - initCustomOrgList={watch("customOrgList")} - onSave={(newList: string[]) => { - setFormValue("customOrgList", newList, { shouldDirty: false }); - setShowCustomOrgListModal(false); - }} + + + ), + }, + ]; + + return ( +
+ - +
); } ); -export default CampusSettingsForm; +export default CampusSettingsForm; \ No newline at end of file