From 81125c94f8deb61a726e4efdb6f1af18bcc2ee5e Mon Sep 17 00:00:00 2001 From: Brandon Cohen Date: Thu, 25 May 2023 22:46:31 -0400 Subject: [PATCH] feat: request and issue count added to sidebar/mobile menu --- src/components/Common/Badge/index.tsx | 8 +++- src/components/IssueDetails/index.tsx | 12 ++++++ src/components/Layout/MobileMenu/index.tsx | 42 +++++++++++++++++-- src/components/Layout/Sidebar/index.tsx | 32 +++++++++++++- src/components/Layout/index.tsx | 24 ++++++++--- src/components/RequestBlock/index.tsx | 13 ++++++ src/components/RequestButton/index.tsx | 13 ++++++ src/components/RequestCard/index.tsx | 13 ++++++ .../RequestList/RequestItem/index.tsx | 13 ++++++ .../RequestModal/CollectionRequestModal.tsx | 15 ++++++- .../RequestModal/MovieRequestModal.tsx | 17 +++++++- .../RequestModal/TvRequestModal.tsx | 5 +++ src/components/RequestModal/index.tsx | 15 +++++++ 13 files changed, 209 insertions(+), 13 deletions(-) diff --git a/src/components/Common/Badge/index.tsx b/src/components/Common/Badge/index.tsx index 17eda5b1d..ec13c05d0 100644 --- a/src/components/Common/Badge/index.tsx +++ b/src/components/Common/Badge/index.tsx @@ -9,7 +9,8 @@ interface BadgeProps { | 'warning' | 'success' | 'dark' - | 'light'; + | 'light' + | 'gradient'; className?: string; href?: string; children: React.ReactNode; @@ -66,6 +67,11 @@ const Badge = ( badgeStyle.push('hover:bg-gray-600'); } break; + case 'gradient': + badgeStyle.push( + 'border border-white bg-gradient-to-br from-indigo-600 to-purple-600 !text-indigo-100 shadow-black' + ); + break; default: badgeStyle.push( 'bg-indigo-500 bg-opacity-80 border border-indigo-500 !text-indigo-100' diff --git a/src/components/IssueDetails/index.tsx b/src/components/IssueDetails/index.tsx index 712d33798..4fbe71452 100644 --- a/src/components/IssueDetails/index.tsx +++ b/src/components/IssueDetails/index.tsx @@ -32,6 +32,7 @@ import { useState } from 'react'; import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; +import useSWRMutation from 'swr/mutation'; import * as Yup from 'yup'; const messages = defineMessages({ @@ -104,6 +105,16 @@ const IssueDetails = () => { (opt) => opt.issueType === issueData?.issueType ); + const fetchIssuesCount = async () => { + const response = await axios.get('/api/v1/request/count'); + return response.data; + }; + + const { trigger: issueTrigger } = useSWRMutation( + '/api/v1/issue/count', + fetchIssuesCount + ); + if (!data && !error) { return ; } @@ -144,6 +155,7 @@ const IssueDetails = () => { autoDismiss: true, }); revalidateIssue(); + issueTrigger(); } catch (e) { addToast(intl.formatMessage(messages.toaststatusupdatefailed), { appearance: 'error', diff --git a/src/components/Layout/MobileMenu/index.tsx b/src/components/Layout/MobileMenu/index.tsx index f6c574f4d..6e6dbee6a 100644 --- a/src/components/Layout/MobileMenu/index.tsx +++ b/src/components/Layout/MobileMenu/index.tsx @@ -1,3 +1,4 @@ +import Badge from '@app/components/Common/Badge'; import { menuMessages } from '@app/components/Layout/Sidebar'; import useClickOutside from '@app/hooks/useClickOutside'; import { Permission, useUser } from '@app/hooks/useUser'; @@ -27,6 +28,11 @@ import { useRouter } from 'next/router'; import { cloneElement, useRef, useState } from 'react'; import { useIntl } from 'react-intl'; +interface MobileMenuProps { + pendingRequestsCount?: number; + openIssuesCount?: number; +} + interface MenuLink { href: string; svgIcon: JSX.Element; @@ -39,7 +45,10 @@ interface MenuLink { dataTestId?: string; } -const MobileMenu = () => { +const MobileMenu = ({ + pendingRequestsCount, + openIssuesCount, +}: MobileMenuProps) => { const ref = useRef(null); const intl = useIntl(); const [isOpen, setIsOpen] = useState(false); @@ -144,7 +153,7 @@ const MobileMenu = () => { return ( { @@ -159,7 +168,23 @@ const MobileMenu = () => { {cloneElement(isActive ? link.svgIconSelected : link.svgIcon, { className: 'h-5 w-5', })} - {link.content} + {link.content} + {link.href === '/requests' && pendingRequestsCount && ( +
+ + {pendingRequestsCount < 100 + ? pendingRequestsCount + : '99+'} + +
+ )} + {link.href === '/issues' && openIssuesCount && ( +
+ + {openIssuesCount < 100 ? openIssuesCount : '99+'} + +
+ )}
); @@ -175,7 +200,7 @@ const MobileMenu = () => { return ( @@ -185,6 +210,15 @@ const MobileMenu = () => { className: 'h-6 w-6', } )} + {link.href === '/requests' && pendingRequestsCount && ( +
+ + {pendingRequestsCount < 100 + ? pendingRequestsCount + : '99+'} + +
+ )}
); diff --git a/src/components/Layout/Sidebar/index.tsx b/src/components/Layout/Sidebar/index.tsx index 81ebb86c7..63596a78c 100644 --- a/src/components/Layout/Sidebar/index.tsx +++ b/src/components/Layout/Sidebar/index.tsx @@ -1,3 +1,4 @@ +import Badge from '@app/components/Common/Badge'; import VersionStatus from '@app/components/Layout/VersionStatus'; import useClickOutside from '@app/hooks/useClickOutside'; import { Permission, useUser } from '@app/hooks/useUser'; @@ -30,6 +31,8 @@ export const menuMessages = defineMessages({ interface SidebarProps { open?: boolean; setClosed: () => void; + pendingRequestsCount?: number; + openIssuesCount?: number; } interface SidebarLinkProps { @@ -98,7 +101,12 @@ const SidebarLinks: SidebarLinkProps[] = [ }, ]; -const Sidebar = ({ open, setClosed }: SidebarProps) => { +const Sidebar = ({ + open, + setClosed, + pendingRequestsCount, + openIssuesCount, +}: SidebarProps) => { const navRef = useRef(null); const router = useRouter(); const intl = useIntl(); @@ -254,6 +262,28 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => { {intl.formatMessage( menuMessages[sidebarLink.messagesKey] )} + {sidebarLink.messagesKey === 'requests' && + pendingRequestsCount && + pendingRequestsCount > 0 && ( +
+ + {pendingRequestsCount < 100 + ? pendingRequestsCount + : '99+'} + +
+ )} + {sidebarLink.messagesKey === 'issues' && + openIssuesCount && + openIssuesCount > 0 && ( +
+ + {openIssuesCount < 100 + ? openIssuesCount + : '99+'} + +
+ )} ); diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index 878f27b18..c3a3e2244 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -10,6 +10,7 @@ import { useUser } from '@app/hooks/useUser'; import { ArrowLeftIcon, Bars3BottomLeftIcon } from '@heroicons/react/24/solid'; import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; +import useSWR from 'swr'; type LayoutProps = { children: React.ReactNode; @@ -22,6 +23,8 @@ const Layout = ({ children }: LayoutProps) => { const router = useRouter(); const { currentSettings } = useSettings(); const { setLocale } = useLocale(); + const { data: requestResponse } = useSWR('/api/v1/request/count'); + const { data: issueResponse } = useSWR('/api/v1/issue/count'); useEffect(() => { if (setLocale && user) { @@ -55,11 +58,22 @@ const Layout = ({ children }: LayoutProps) => {
- setSidebarOpen(false)} /> -
- -
- + {requestResponse && issueResponse && ( + setSidebarOpen(false)} + pendingRequestsCount={requestResponse.pending} + openIssuesCount={issueResponse.open} + /> + )} + {requestResponse && issueResponse && ( +
+ +
+ )}
{ const { profile, rootFolder, server, languageProfile } = useRequestOverride(request); + const fetchRequestsCount = async () => { + const response = await axios.get('/api/v1/request/count'); + return response.data; + }; + + const { trigger: requestTrigger } = useSWRMutation( + '/api/v1/request/count', + fetchRequestsCount + ); + const updateRequest = async (type: 'approve' | 'decline'): Promise => { setIsUpdating(true); await axios.post(`/api/v1/request/${request.id}/${type}`); if (onUpdate) { onUpdate(); + requestTrigger(); } setIsUpdating(false); }; @@ -66,6 +78,7 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => { if (onUpdate) { onUpdate(); + requestTrigger(); } setIsUpdating(false); diff --git a/src/components/RequestButton/index.tsx b/src/components/RequestButton/index.tsx index 56e91810b..bf52398b9 100644 --- a/src/components/RequestButton/index.tsx +++ b/src/components/RequestButton/index.tsx @@ -15,6 +15,7 @@ import type { MediaRequest } from '@server/entity/MediaRequest'; import axios from 'axios'; import { useMemo, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; +import useSWRMutation from 'swr/mutation'; const messages = defineMessages({ viewrequest: 'View Request', @@ -89,6 +90,16 @@ const RequestButton = ({ : undefined; }, [active4kRequests, user]); + const fetchRequestsCount = async () => { + const response = await axios.get('/api/v1/request/count'); + return response.data; + }; + + const { trigger: requestTrigger } = useSWRMutation( + '/api/v1/request/count', + fetchRequestsCount + ); + const modifyRequest = async ( request: MediaRequest, type: 'approve' | 'decline' @@ -97,6 +108,7 @@ const RequestButton = ({ if (response) { onUpdate(); + requestTrigger(); } }; @@ -115,6 +127,7 @@ const RequestButton = ({ ); onUpdate(); + requestTrigger(); }; const buttons: ButtonOption[] = []; diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index 44abd555a..7a9368a7a 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -27,6 +27,7 @@ import { useInView } from 'react-intersection-observer'; import { defineMessages, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR, { mutate } from 'swr'; +import useSWRMutation from 'swr/mutation'; const messages = defineMessages({ seasons: '{seasonCount, plural, one {Season} other {Seasons}}', @@ -247,17 +248,29 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { iOSPlexUrl4k: requestData?.media?.iOSPlexUrl4k, }); + const fetchRequestsCount = async () => { + const response = await axios.get('/api/v1/request/count'); + return response.data; + }; + + const { trigger: requestTrigger } = useSWRMutation( + '/api/v1/request/count', + fetchRequestsCount + ); + const modifyRequest = async (type: 'approve' | 'decline') => { const response = await axios.post(`/api/v1/request/${request.id}/${type}`); if (response) { revalidate(); + requestTrigger(); } }; const deleteRequest = async () => { await axios.delete(`/api/v1/request/${request.id}`); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); + requestTrigger(); }; const retryRequest = async () => { diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index a42483abe..9f3b540f2 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -26,6 +26,7 @@ import { useInView } from 'react-intersection-observer'; import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; +import useSWRMutation from 'swr/mutation'; const messages = defineMessages({ seasons: '{seasonCount, plural, one {Season} other {Seasons}}', @@ -306,11 +307,22 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { const [isRetrying, setRetrying] = useState(false); + const fetchRequestsCount = async () => { + const response = await axios.get('/api/v1/request/count'); + return response.data; + }; + + const { trigger: requestTrigger } = useSWRMutation( + '/api/v1/request/count', + fetchRequestsCount + ); + const modifyRequest = async (type: 'approve' | 'decline') => { const response = await axios.post(`/api/v1/request/${request.id}/${type}`); if (response) { revalidate(); + requestTrigger(); } }; @@ -318,6 +330,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { await axios.delete(`/api/v1/request/${request.id}`); revalidateList(); + requestTrigger(); }; const retryRequest = async () => { diff --git a/src/components/RequestModal/CollectionRequestModal.tsx b/src/components/RequestModal/CollectionRequestModal.tsx index 614a00daf..e2c301000 100644 --- a/src/components/RequestModal/CollectionRequestModal.tsx +++ b/src/components/RequestModal/CollectionRequestModal.tsx @@ -36,6 +36,7 @@ interface RequestModalProps extends React.HTMLAttributes { onCancel?: () => void; onComplete?: (newStatus: MediaStatus) => void; onUpdating?: (isUpdating: boolean) => void; + requestTrigger: () => void; } const CollectionRequestModal = ({ @@ -44,6 +45,7 @@ const CollectionRequestModal = ({ tmdbId, onUpdating, is4k = false, + requestTrigger, }: RequestModalProps) => { const [isUpdating, setIsUpdating] = useState(false); const [requestOverrides, setRequestOverrides] = @@ -211,6 +213,7 @@ const CollectionRequestModal = ({ ? MediaStatus.UNKNOWN : MediaStatus.PARTIALLY_AVAILABLE ); + requestTrigger(); } addToast( @@ -230,7 +233,17 @@ const CollectionRequestModal = ({ } finally { setIsUpdating(false); } - }, [requestOverrides, data, onComplete, addToast, intl, selectedParts, is4k]); + }, [ + requestOverrides, + data?.parts, + data?.name, + onComplete, + addToast, + intl, + selectedParts, + is4k, + requestTrigger, + ]); const hasAutoApprove = hasPermission( [ diff --git a/src/components/RequestModal/MovieRequestModal.tsx b/src/components/RequestModal/MovieRequestModal.tsx index 76a126388..770bcc6a5 100644 --- a/src/components/RequestModal/MovieRequestModal.tsx +++ b/src/components/RequestModal/MovieRequestModal.tsx @@ -42,6 +42,7 @@ interface RequestModalProps extends React.HTMLAttributes { onCancel?: () => void; onComplete?: (newStatus: MediaStatus) => void; onUpdating?: (isUpdating: boolean) => void; + requestTrigger: () => void; } const MovieRequestModal = ({ @@ -51,6 +52,7 @@ const MovieRequestModal = ({ onUpdating, editRequest, is4k = false, + requestTrigger, }: RequestModalProps) => { const [isUpdating, setIsUpdating] = useState(false); const [requestOverrides, setRequestOverrides] = @@ -95,6 +97,7 @@ const MovieRequestModal = ({ ...overrideParams, }); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); + requestTrigger(); if (response.data) { if (onComplete) { @@ -129,7 +132,17 @@ const MovieRequestModal = ({ } finally { setIsUpdating(false); } - }, [data, onComplete, addToast, requestOverrides, hasPermission, intl, is4k]); + }, [ + requestOverrides, + data?.id, + data?.title, + is4k, + requestTrigger, + onComplete, + addToast, + intl, + hasPermission, + ]); const cancelRequest = async () => { setIsUpdating(true); @@ -139,6 +152,7 @@ const MovieRequestModal = ({ `/api/v1/request/${editRequest?.id}` ); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); + requestTrigger(); if (response.status === 204) { if (onComplete) { @@ -176,6 +190,7 @@ const MovieRequestModal = ({ await axios.post(`/api/v1/request/${editRequest?.id}/approve`); } mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); + requestTrigger(); addToast( diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index 25c8fd3c2..3e5203697 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -56,6 +56,7 @@ interface RequestModalProps extends React.HTMLAttributes { onCancel?: () => void; onComplete?: (newStatus: MediaStatus) => void; onUpdating?: (isUpdating: boolean) => void; + requestTrigger: () => void; is4k?: boolean; editRequest?: MediaRequest; } @@ -67,6 +68,7 @@ const TvRequestModal = ({ onUpdating, editRequest, is4k = false, + requestTrigger, }: RequestModalProps) => { const settings = useSettings(); const { addToast } = useToasts(); @@ -106,6 +108,7 @@ const TvRequestModal = ({ if (onUpdating) { onUpdating(true); + requestTrigger(); } try { @@ -128,6 +131,7 @@ const TvRequestModal = ({ await axios.delete(`/api/v1/request/${editRequest.id}`); } mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); + requestTrigger(); addToast( @@ -176,6 +180,7 @@ const TvRequestModal = ({ if (onUpdating) { onUpdating(true); + requestTrigger(); } try { diff --git a/src/components/RequestModal/index.tsx b/src/components/RequestModal/index.tsx index 9ef6b4057..828aa443f 100644 --- a/src/components/RequestModal/index.tsx +++ b/src/components/RequestModal/index.tsx @@ -4,6 +4,8 @@ import TvRequestModal from '@app/components/RequestModal/TvRequestModal'; import { Transition } from '@headlessui/react'; import type { MediaStatus } from '@server/constants/media'; import type { MediaRequest } from '@server/entity/MediaRequest'; +import axios from 'axios'; +import useSWRMutation from 'swr/mutation'; interface RequestModalProps { show: boolean; @@ -26,6 +28,16 @@ const RequestModal = ({ onUpdating, onCancel, }: RequestModalProps) => { + const fetchRequestsCount = async () => { + const response = await axios.get('/api/v1/request/count'); + return response.data; + }; + + const { trigger: requestTrigger } = useSWRMutation( + '/api/v1/request/count', + fetchRequestsCount + ); + return ( ) : type === 'tv' ? ( ) : ( )}