diff --git a/package-lock.json b/package-lock.json index 39538ab..99bb5fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "react-router-dom": "^6.23.0", "react-scripts": "^5.0.1", "react-tag-autocomplete": "^7.0.1", + "react-toastify": "^11.0.5", "web-vitals": "^3.3.1" }, "devDependencies": { @@ -3981,15 +3982,6 @@ "node": ">= 6" } }, - "node_modules/@trysound/sax": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", - "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", - "license": "ISC", - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -6225,6 +6217,15 @@ "wrap-ansi": "^7.0.0" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -11836,9 +11837,9 @@ } }, "node_modules/jsonpath": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/jsonpath/-/jsonpath-1.2.1.tgz", - "integrity": "sha512-Jl6Jhk0jG+kP3yk59SSeGq7LFPR4JQz1DU0K+kXTysUhMostbhU3qh5mjTuf0PqFcXpAT7kvmMt9WxV10NyIgQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/jsonpath/-/jsonpath-1.3.0.tgz", + "integrity": "sha512-0kjkYHJBkAy50Z5QzArZ7udmvxrJzkpKYW27fiF//BrMY7TQibYLl+FYIXN2BiYmwMIVzSfD8aDRj6IzgBX2/w==", "license": "MIT", "dependencies": { "esprima": "1.2.5", @@ -14325,6 +14326,15 @@ "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", "license": "CC0-1.0" }, + "node_modules/postcss-svgo/node_modules/sax": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz", + "integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/postcss-svgo/node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -14335,17 +14345,17 @@ } }, "node_modules/postcss-svgo/node_modules/svgo": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz", - "integrity": "sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.2.tgz", + "integrity": "sha512-TyzE4NVGLUFy+H/Uy4N6c3G0HEeprsVfge6Lmq+0FdQQ/zqoVYB62IsBZORsiL+o96s6ff/V6/3UQo/C0cgCAA==", "license": "MIT", "dependencies": { - "@trysound/sax": "0.2.0", "commander": "^7.2.0", "css-select": "^4.1.3", "css-tree": "^1.1.3", "csso": "^4.2.0", "picocolors": "^1.0.0", + "sax": "^1.5.0", "stable": "^0.1.8" }, "bin": { @@ -15016,6 +15026,19 @@ "react": "^18.0.0 || ^19.0.0" } }, + "node_modules/react-toastify": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz", + "integrity": "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/package.json b/package.json index 0c80bd8..c6d1f85 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "react-router-dom": "^6.23.0", "react-scripts": "^5.0.1", "react-tag-autocomplete": "^7.0.1", + "react-toastify": "^11.0.5", "web-vitals": "^3.3.1" }, "scripts": { diff --git a/src/admin/Admin.jsx b/src/admin/Admin.jsx index 7e8960b..eb11523 100644 --- a/src/admin/Admin.jsx +++ b/src/admin/Admin.jsx @@ -110,15 +110,23 @@ function UsersTable() { const [order, setOrder] = useState('desc'); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); + const [usersError, setUsersError] = useState(''); useEffect(() => { setLoading(true); + setUsersError(''); axios.get( `${constants.SERVER_URL}/api/admin/users?page=${page}&sort=${sort}&order=${order}`, { withCredentials: true } ) - .then(res => { if (res.data.success) setData(res.data); }) - .catch(() => {}) + .then(res => { + if (res.data.success) { + setData(res.data); + } else { + setUsersError(res.data.message || 'Failed to load users. Try again.'); + } + }) + .catch(() => setUsersError('Failed to load users. Try again.')) .finally(() => setLoading(false)); }, [page, sort, order]); @@ -168,6 +176,11 @@ function UsersTable() { + {!loading && usersError && ( +
+ {usersError} +
+ )} {loading ? (
diff --git a/src/app/Navbar.jsx b/src/app/Navbar.jsx index cc7c76c..af9add0 100644 --- a/src/app/Navbar.jsx +++ b/src/app/Navbar.jsx @@ -3,6 +3,7 @@ import NewTypeModal from "./components/modals/NewTypeModal"; import ImportModal from "./components/modals/ImportModal"; import { useNavigate } from 'react-router-dom'; import axios from 'axios'; +import { toast } from 'react-toastify'; import { useClickOutside } from './hooks/useClickOutside'; const constants = require('./constants'); const theme = require('../styling/theme'); @@ -133,9 +134,9 @@ const NavbarFunction = ({user, setUserChanged, newTypes, isAdmin}) => { const onCreateNewType = (newName) => { newName = newName.trim().toLowerCase().replace(/ /g, '-'); if(user.newTypes[newName]) { - window.alert('Type Already Exists'); + toast.error('Type already exists'); } else if(Object.keys(user.newTypes).length === constants.maxCustomTypes) { - window.alert(`Maximum custom types reached (${constants.maxCustomTypes})`); + toast.error(`Maximum custom types reached (${constants.maxCustomTypes})`); } else { axios .put(constants['SERVER_URL'] + `/api/user/newTypes`, {newType: newName}) @@ -144,7 +145,7 @@ const NavbarFunction = ({user, setUserChanged, newTypes, isAdmin}) => { setPendingNewType(newName); setUserChanged(true); }) - .catch(() => window.alert("Error on Create New Type")); + .catch(() => toast.error("Error creating new type")); } }; @@ -163,11 +164,11 @@ const NavbarFunction = ({user, setUserChanged, newTypes, isAdmin}) => { link.click(); document.body.removeChild(link); } else { - window.alert('Failed to export data'); + toast.error('Failed to export data'); } } catch (error) { console.error('Export error:', error); - window.alert('Error exporting data'); + toast.error('Error exporting data'); } }; diff --git a/src/app/Navbar.test.jsx b/src/app/Navbar.test.jsx index 4d4a62b..cc52984 100644 --- a/src/app/Navbar.test.jsx +++ b/src/app/Navbar.test.jsx @@ -1,8 +1,16 @@ import { renderWithRouter, screen, fireEvent, waitFor } from '../test-utils'; import axios from 'axios'; import Navbar from './Navbar'; +import { toast } from 'react-toastify'; jest.mock('axios'); +jest.mock('react-toastify', () => ({ + toast: { + error: jest.fn(), + success: jest.fn(), + }, + ToastContainer: () => null, +})); jest.mock('./components/modals/NewTypeModal', () => ({ show }) => show ?
: null ); @@ -30,7 +38,6 @@ beforeEach(() => { jest.useFakeTimers(); axios.get.mockResolvedValue({ data: { success: true, incoming: [] } }); axios.put.mockResolvedValue({}); - window.alert = jest.fn(); }); afterEach(() => { @@ -138,6 +145,6 @@ describe('Navbar', () => { // Modal is mocked, so simulate the onSaveClick by accessing internal state is not possible // Instead test by calling the handler directly through the modal interaction stub // This test verifies the alert path exists — covered indirectly by the component - expect(window.alert).not.toHaveBeenCalled(); // sanity — no alert yet + expect(toast.error).not.toHaveBeenCalled(); // sanity — no toast yet }); }); diff --git a/src/app/components/modals/ShareLinkModal.jsx b/src/app/components/modals/ShareLinkModal.jsx index 3b15be1..558b56a 100644 --- a/src/app/components/modals/ShareLinkModal.jsx +++ b/src/app/components/modals/ShareLinkModal.jsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import axios from 'axios'; +import { toast } from 'react-toastify'; import { toCapitalNotation } from '../../helpers'; import Modal from './Modal'; const constants = require('../../constants'); @@ -70,7 +71,7 @@ function ShareLinkModal({ .catch(err => { console.error(err); setIsGeneratingLink(false); - window.alert('Error generating link'); + toast.error('Error generating link'); }); } @@ -88,7 +89,7 @@ function ShareLinkModal({ }) .catch(err => { console.error(err); - window.alert('Error revoking link'); + toast.error('Error revoking link'); }); } diff --git a/src/app/components/modals/ShareLinkModal.test.jsx b/src/app/components/modals/ShareLinkModal.test.jsx index f8ccdd6..41163cd 100644 --- a/src/app/components/modals/ShareLinkModal.test.jsx +++ b/src/app/components/modals/ShareLinkModal.test.jsx @@ -1,8 +1,16 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import axios from 'axios'; import ShareLinkModal from './ShareLinkModal'; +import { toast } from 'react-toastify'; jest.mock('axios'); +jest.mock('react-toastify', () => ({ + toast: { + error: jest.fn(), + success: jest.fn(), + }, + ToastContainer: () => null, +})); const defaultProps = { show: true, @@ -26,7 +34,7 @@ beforeEach(() => { configurable: true, }); window.confirm = jest.fn(() => true); - window.alert = jest.fn(); + toast.error.mockClear(); }); describe('ShareLinkModal', () => { diff --git a/src/app/pages/CreateMedia.jsx b/src/app/pages/CreateMedia.jsx index 95d7560..3766879 100644 --- a/src/app/pages/CreateMedia.jsx +++ b/src/app/pages/CreateMedia.jsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { Link, useParams, useNavigate, useLocation } from 'react-router-dom'; import axios from 'axios'; +import { toast } from 'react-toastify'; import PageMeta from '../components/ui/PageMeta'; import TagMaker from "../components/TagMaker"; import { toCapitalNotation } from "../helpers"; @@ -183,7 +184,7 @@ const CreateMedia = ({user, toDo, newType, selectedTags, dataSource = 'api', bas navigate(-1); }) .catch((err) => { - window.alert("Create Failed :(") + toast.error("Create failed"); }); }; diff --git a/src/app/pages/CreateMedia.test.jsx b/src/app/pages/CreateMedia.test.jsx index c7cbc0e..ed3af5f 100644 --- a/src/app/pages/CreateMedia.test.jsx +++ b/src/app/pages/CreateMedia.test.jsx @@ -4,8 +4,16 @@ import { MemoryRouter, Routes, Route } from 'react-router-dom'; import { HelmetProvider } from 'react-helmet-async'; import axios from 'axios'; import CreateMedia from './CreateMedia'; +import { toast } from 'react-toastify'; jest.mock('axios'); +jest.mock('react-toastify', () => ({ + toast: { + error: jest.fn(), + success: jest.fn(), + }, + ToastContainer: () => null, +})); jest.mock('../components/TagMaker', () => () =>
); const mockUser = { @@ -44,7 +52,7 @@ function renderCreateMedia(props = {}) { beforeEach(() => { jest.clearAllMocks(); axios.post.mockResolvedValue({ data: {} }); - window.alert = jest.fn(); + toast.error.mockClear(); }); describe('CreateMedia', () => { diff --git a/src/app/pages/Friends.jsx b/src/app/pages/Friends.jsx index 75007bc..12a046b 100644 --- a/src/app/pages/Friends.jsx +++ b/src/app/pages/Friends.jsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import axios from 'axios'; import { useNavigate } from 'react-router-dom'; +import { toast } from 'react-toastify'; import PageMeta from '../components/ui/PageMeta'; const constants = require('../constants'); const theme = require('../../styling/theme'); @@ -56,7 +57,7 @@ function Friends({ user, setUserChanged }) { } } catch (err) { console.error('Error accepting friend request:', err); - window.alert(err.response?.data?.message || 'Failed to accept friend request'); + toast.error(err.response?.data?.message || 'Failed to accept friend request'); } }; @@ -73,7 +74,7 @@ function Friends({ user, setUserChanged }) { } } catch (err) { console.error('Error rejecting friend request:', err); - window.alert(err.response?.data?.message || 'Failed to reject friend request'); + toast.error(err.response?.data?.message || 'Failed to reject friend request'); } }; @@ -94,7 +95,7 @@ function Friends({ user, setUserChanged }) { } } catch (err) { console.error('Error removing friend:', err); - window.alert(err.response?.data?.message || 'Failed to remove friend'); + toast.error(err.response?.data?.message || 'Failed to remove friend'); } }; diff --git a/src/app/pages/Profile.jsx b/src/app/pages/Profile.jsx index 51f4c99..0754d2c 100644 --- a/src/app/pages/Profile.jsx +++ b/src/app/pages/Profile.jsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import axios from 'axios'; import { useNavigate, useParams } from 'react-router-dom'; +import { toast } from 'react-toastify'; import PageMeta from '../components/ui/PageMeta'; import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, KeyboardSensor } from '@dnd-kit/core'; import { arrayMove, SortableContext, rectSortingStrategy, useSortable, sortableKeyboardCoordinates } from '@dnd-kit/sortable'; @@ -79,7 +80,7 @@ function Profile({ user: currentUser, setUserChanged }) { } } catch (err) { console.error('Error updating visibility:', err); - window.alert('Failed to update visibility'); + toast.error('Failed to update visibility'); } finally { setIsUpdatingVisibility(false); } @@ -146,11 +147,11 @@ function Profile({ user: currentUser, setUserChanged }) { } if (response && !response.data.success) { - window.alert(response.data.message || 'Action failed'); + toast.error(response.data.message || 'Action failed'); } } catch (err) { console.error(`Error ${action}ing friend:`, err); - window.alert(err.response?.data?.message || `Failed to ${action} friend`); + toast.error(err.response?.data?.message || `Failed to ${action} friend`); } finally { setIsUpdatingFriendship(false); } diff --git a/src/app/pages/ShowMediaDetails.jsx b/src/app/pages/ShowMediaDetails.jsx index fb35094..fa6b10f 100644 --- a/src/app/pages/ShowMediaDetails.jsx +++ b/src/app/pages/ShowMediaDetails.jsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useMemo, useRef } from 'react'; import { Link, useParams, useNavigate, useLocation } from 'react-router-dom'; import axios from 'axios'; +import { toast } from 'react-toastify'; import PageMeta from '../components/ui/PageMeta'; import DeleteModal from "../components/modals/DeleteModal"; import TagMaker from "../components/TagMaker"; @@ -93,7 +94,15 @@ function ShowMediaDetails({ setLoaded(true); } }) - .catch(() => {}); + .catch((err) => { + const status = err?.response?.status; + if (status === 404) { + navigate('/404'); + return; + } + toast.error(err?.response?.data?.message || 'Could not load media.'); + setLoaded(true); + }); } } }, [loaded, mediaType, group, navigate, mediaList, dataSource, onGetMediaById, propMediaList]); @@ -161,6 +170,7 @@ function ShowMediaDetails({ }) .catch((err) => { setTempMedia(media); + toast.error(err?.response?.data?.message || 'Failed to save changes.'); }); }; @@ -189,7 +199,9 @@ function ShowMediaDetails({ const finalUrl = currentSearch ? `${backUrl}${currentSearch}` : backUrl; navigate(finalUrl); }) - .catch(() => {}); + .catch((err) => { + toast.error(err?.response?.data?.message || 'Failed to delete.'); + }); } }; @@ -228,7 +240,7 @@ function ShowMediaDetails({ setDuplicateId(created.ID); setShowDuplicateModal(true); } else { - window.alert('Failed to duplicate media'); + toast.error('Failed to duplicate media'); } return; } @@ -240,7 +252,7 @@ function ShowMediaDetails({ setShowDuplicateModal(true); }) .catch((err) => { - window.alert('Failed to duplicate media'); + toast.error('Failed to duplicate media'); }); } diff --git a/src/app/pages/ShowMediaDetails.test.jsx b/src/app/pages/ShowMediaDetails.test.jsx index 9fe9682..f415158 100644 --- a/src/app/pages/ShowMediaDetails.test.jsx +++ b/src/app/pages/ShowMediaDetails.test.jsx @@ -4,8 +4,16 @@ import { MemoryRouter, Routes, Route } from 'react-router-dom'; import { HelmetProvider } from 'react-helmet-async'; import axios from 'axios'; import ShowMediaDetails from './ShowMediaDetails'; +import { toast } from 'react-toastify'; jest.mock('axios'); +jest.mock('react-toastify', () => ({ + toast: { + error: jest.fn(), + success: jest.fn(), + }, + ToastContainer: () => null, +})); // DeleteModal manages its own show/hide via internal state. // Use require inside the factory (variables must be prefixed with 'mock' to be allowed by Jest). @@ -79,7 +87,7 @@ beforeEach(() => { axios.get.mockResolvedValue({ data: mockMedia }); axios.put.mockResolvedValue({ data: mockMedia }); axios.delete.mockResolvedValue({ data: { toDo: false } }); - window.alert = jest.fn(); + toast.error.mockClear(); }); describe('ShowMediaDetails', () => { diff --git a/src/app/pages/ShowMediaList.jsx b/src/app/pages/ShowMediaList.jsx index b944b73..dfbea22 100644 --- a/src/app/pages/ShowMediaList.jsx +++ b/src/app/pages/ShowMediaList.jsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import axios from 'axios'; +import { toast } from 'react-toastify'; import { Link, useParams, useNavigate, useLocation } from 'react-router-dom'; import PageMeta from '../components/ui/PageMeta'; @@ -248,7 +249,7 @@ function ShowMediaList({ navigate('/'); }) .catch(err => { - window.alert('Error deleting media type'); + toast.error('Error deleting media type'); console.error(err); }); } @@ -285,7 +286,9 @@ function ShowMediaList({ if (dataSource === 'demo' && onMoveToTier) { onMoveToTier(activeId, targetTier, updatedTargetTier.length - 1); } else if (dataSource === 'api') { - axios.put(constants['SERVER_URL'] + `/api/media/${mediaType}/${activeId}`, { tier: targetTier }).catch(() => {}); + axios + .put(constants['SERVER_URL'] + `/api/media/${mediaType}/${activeId}`, { tier: targetTier }) + .catch(() => toast.error('Failed to save changes. Please try again.')); } } return; @@ -316,7 +319,9 @@ function ShowMediaList({ if (dataSource === 'demo' && onReorderInTier) { onReorderInTier(sourceTier, toDoState, updatedList.map(m => m.ID)); } else if (dataSource === 'api') { - axios.put(constants['SERVER_URL'] + `/api/media/${mediaType}/${toDoString}/${sourceTier}/reorder`, { orderedIds: updatedList.map(m => m.ID) }).catch(() => {}); + axios + .put(constants['SERVER_URL'] + `/api/media/${mediaType}/${toDoString}/${sourceTier}/reorder`, { orderedIds: updatedList.map(m => m.ID) }) + .catch(() => toast.error('Failed to save order. Please try again.')); } } else { const fromList = [...(localByTier[sourceTier] || [])]; @@ -331,7 +336,9 @@ function ShowMediaList({ if (dataSource === 'demo' && onMoveToTier) { onMoveToTier(activeId, destTier, destIndex); } else if (dataSource === 'api') { - axios.put(constants['SERVER_URL'] + `/api/media/${mediaType}/${activeId}`, { tier: destTier, orderIndex: destIndex }).catch(() => {}); + axios + .put(constants['SERVER_URL'] + `/api/media/${mediaType}/${activeId}`, { tier: destTier, orderIndex: destIndex }) + .catch(() => toast.error('Failed to save changes. Please try again.')); } } }; diff --git a/src/index.js b/src/index.js index cd7961b..f373bc6 100644 --- a/src/index.js +++ b/src/index.js @@ -3,6 +3,8 @@ import ReactDOM from 'react-dom/client'; import './styling/index.css'; import './styling/App.css'; import "@fortawesome/fontawesome-free/css/all.min.css"; +import { ToastContainer } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; import App from './App'; import reportWebVitals from './reportWebVitals'; @@ -11,6 +13,7 @@ const root = ReactDOM.createRoot(document.getElementById('root')); root.render( + );