From 2348f23f433195d64dee3e6eeede296fca5fdbc9 Mon Sep 17 00:00:00 2001 From: Joaquin Olivero <66050823+JoaquinOlivero@users.noreply.github.com> Date: Wed, 7 Aug 2024 08:46:57 -0300 Subject: [PATCH] feat: Option on item's page to add/remove from watchlist (#781) * feat: adds button on the page of a media item to add or remove it from a user's watchlist re #730 * fix: whitespace and i18n key * style: fix code format to the required standards * refactor: change axios for the fetch api --------- Co-authored-by: JoaquinOlivero --- server/models/Movie.ts | 5 +- server/models/Tv.ts | 5 +- server/routes/movie.ts | 15 ++- server/routes/tv.ts | 13 ++- src/components/MovieDetails/index.tsx | 126 +++++++++++++++++++++++- src/components/TvDetails/index.tsx | 135 +++++++++++++++++++++++++- src/i18n/locale/en.json | 10 ++ 7 files changed, 302 insertions(+), 7 deletions(-) diff --git a/server/models/Movie.ts b/server/models/Movie.ts index 0b627859f..87ea79360 100644 --- a/server/models/Movie.ts +++ b/server/models/Movie.ts @@ -85,6 +85,7 @@ export interface MovieDetails { mediaUrl?: string; watchProviders?: WatchProviders[]; keywords: Keyword[]; + onUserWatchlist?: boolean; } export const mapProductionCompany = ( @@ -101,7 +102,8 @@ export const mapProductionCompany = ( export const mapMovieDetails = ( movie: TmdbMovieDetails, - media?: Media + media?: Media, + userWatchlist?: boolean ): MovieDetails => ({ id: movie.id, adult: movie.adult, @@ -148,4 +150,5 @@ export const mapMovieDetails = ( id: keyword.id, name: keyword.name, })), + onUserWatchlist: userWatchlist, }); diff --git a/server/models/Tv.ts b/server/models/Tv.ts index 24362b504..c79f93117 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -111,6 +111,7 @@ export interface TvDetails { keywords: Keyword[]; mediaInfo?: Media; watchProviders?: WatchProviders[]; + onUserWatchlist?: boolean; } const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({ @@ -161,7 +162,8 @@ export const mapNetwork = (network: TmdbNetwork): TvNetwork => ({ export const mapTvDetails = ( show: TmdbTvDetails, - media?: Media + media?: Media, + userWatchlist?: boolean ): TvDetails => ({ createdBy: show.created_by, episodeRunTime: show.episode_run_time, @@ -223,4 +225,5 @@ export const mapTvDetails = ( })), mediaInfo: media, watchProviders: mapWatchProviders(show['watch/providers']?.results ?? {}), + onUserWatchlist: userWatchlist, }); diff --git a/server/routes/movie.ts b/server/routes/movie.ts index b48ae9ea8..833e92554 100644 --- a/server/routes/movie.ts +++ b/server/routes/movie.ts @@ -3,7 +3,9 @@ import RottenTomatoes from '@server/api/rating/rottentomatoes'; import { type RatingResponse } from '@server/api/ratings'; import TheMovieDb from '@server/api/themoviedb'; import { MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; +import { Watchlist } from '@server/entity/Watchlist'; import logger from '@server/logger'; import { mapMovieDetails } from '@server/models/Movie'; import { mapMovieResult } from '@server/models/Search'; @@ -22,7 +24,18 @@ movieRoutes.get('/:id', async (req, res, next) => { const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE); - return res.status(200).json(mapMovieDetails(tmdbMovie, media)); + const onUserWatchlist = await getRepository(Watchlist).exist({ + where: { + tmdbId: Number(req.params.id), + requestedBy: { + id: req.user?.id, + }, + }, + }); + + return res + .status(200) + .json(mapMovieDetails(tmdbMovie, media, onUserWatchlist)); } catch (e) { logger.debug('Something went wrong retrieving movie', { label: 'API', diff --git a/server/routes/tv.ts b/server/routes/tv.ts index cd69c13a9..2f42c0dc6 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -1,7 +1,9 @@ import RottenTomatoes from '@server/api/rating/rottentomatoes'; import TheMovieDb from '@server/api/themoviedb'; import { MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; +import { Watchlist } from '@server/entity/Watchlist'; import logger from '@server/logger'; import { mapTvResult } from '@server/models/Search'; import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv'; @@ -19,7 +21,16 @@ tvRoutes.get('/:id', async (req, res, next) => { const media = await Media.getMedia(tv.id, MediaType.TV); - return res.status(200).json(mapTvDetails(tv, media)); + const onUserWatchlist = await getRepository(Watchlist).exist({ + where: { + tmdbId: Number(req.params.id), + requestedBy: { + id: req.user?.id, + }, + }, + }); + + return res.status(200).json(mapTvDetails(tv, media, onUserWatchlist)); } catch (e) { logger.debug('Something went wrong retrieving series', { label: 'API', diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 1c53ac0b4..b565fdb10 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -3,6 +3,7 @@ import RTAudRotten from '@app/assets/rt_aud_rotten.svg'; import RTFresh from '@app/assets/rt_fresh.svg'; import RTRotten from '@app/assets/rt_rotten.svg'; import ImdbLogo from '@app/assets/services/imdb.svg'; +import Spinner from '@app/assets/spinner.svg'; import TmdbLogo from '@app/assets/tmdb_logo.svg'; import Button from '@app/components/Common/Button'; import CachedImage from '@app/components/Common/CachedImage'; @@ -41,12 +42,16 @@ import { import { ChevronDoubleDownIcon, ChevronDoubleUpIcon, + MinusCircleIcon, + StarIcon, } from '@heroicons/react/24/solid'; import { type RatingResponse } from '@server/api/ratings'; import { IssueStatus } from '@server/constants/issue'; -import { MediaStatus } from '@server/constants/media'; +import { MediaStatus, MediaType } from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; +import type { Watchlist } from '@server/entity/Watchlist'; import type { MovieDetails as MovieDetailsType } from '@server/models/Movie'; +import axios from 'axios'; import { countries } from 'country-flag-icons'; import 'country-flag-icons/3x2/flags.css'; import { uniqBy } from 'lodash'; @@ -55,6 +60,7 @@ import Link from 'next/link'; import { useRouter } from 'next/router'; import { useEffect, useMemo, useState } from 'react'; import { useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; const messages = defineMessages('components.MovieDetails', { @@ -94,6 +100,12 @@ const messages = defineMessages('components.MovieDetails', { rtaudiencescore: 'Rotten Tomatoes Audience Score', tmdbuserscore: 'TMDB User Score', imdbuserscore: 'IMDB User Score', + watchlistSuccess: '{title} added to watchlist successfully!', + watchlistDeleted: + '{title} Removed from watchlist successfully!', + watchlistError: 'Something went wrong try again.', + removefromwatchlist: 'Remove From Watchlist', + addtowatchlist: 'Add To Watchlist', }); interface MovieDetailsProps { @@ -112,7 +124,12 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { const minStudios = 3; const [showMoreStudios, setShowMoreStudios] = useState(false); const [showIssueModal, setShowIssueModal] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [toggleWatchlist, setToggleWatchlist] = useState( + !movie?.onUserWatchlist + ); const { publicRuntimeConfig } = getConfig(); + const { addToast } = useToasts(); const { data, @@ -287,6 +304,79 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { return intl.formatMessage(messages.play4k, { mediaServerName: 'Jellyfin' }); } + const onClickWatchlistBtn = async (): Promise => { + setIsUpdating(true); + + const res = await fetch('/api/v1/watchlist', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tmdbId: movie?.id, + mediaType: MediaType.MOVIE, + title: movie?.title, + }), + }); + + if (!res.ok) { + addToast(intl.formatMessage(messages.watchlistError), { + appearance: 'error', + autoDismiss: true, + }); + + setIsUpdating(false); + return; + } + + const data = await res.json(); + + if (data) { + addToast( + + {intl.formatMessage(messages.watchlistSuccess, { + title: movie?.title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + } + + setIsUpdating(false); + setToggleWatchlist((prevState) => !prevState); + }; + + const onClickDeleteWatchlistBtn = async (): Promise => { + setIsUpdating(true); + try { + const response = await axios.delete( + '/api/v1/watchlist/' + movie?.id + ); + + if (response.status === 204) { + addToast( + + {intl.formatMessage(messages.watchlistDeleted, { + title: movie?.title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'info', autoDismiss: true } + ); + } + } catch (e) { + addToast(intl.formatMessage(messages.watchlistError), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + setIsUpdating(false); + setToggleWatchlist((prevState) => !prevState); + } + }; + return (
{
+ <> + {toggleWatchlist ? ( + + + + ) : ( + + + + )} + {title} added to watchlist successfully!', + watchlistDeleted: + '{title} Removed from watchlist successfully!', + watchlistError: 'Something went wrong try again.', + removefromwatchlist: 'Remove From Watchlist', + addtowatchlist: 'Add To Watchlist', }); interface TvDetailsProps { @@ -106,7 +122,12 @@ const TvDetails = ({ tv }: TvDetailsProps) => { router.query.manage == '1' ? true : false ); const [showIssueModal, setShowIssueModal] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [toggleWatchlist, setToggleWatchlist] = useState( + !tv?.onUserWatchlist + ); const { publicRuntimeConfig } = getConfig(); + const { addToast } = useToasts(); const { data, @@ -302,6 +323,82 @@ const TvDetails = ({ tv }: TvDetailsProps) => { return intl.formatMessage(messages.play4k, { mediaServerName: 'Jellyfin' }); } + const onClickWatchlistBtn = async (): Promise => { + setIsUpdating(true); + + const res = await fetch('/api/v1/watchlist', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tmdbId: tv?.id, + mediaType: MediaType.TV, + title: tv?.name, + }), + }); + + if (!res.ok) { + addToast(intl.formatMessage(messages.watchlistError), { + appearance: 'error', + autoDismiss: true, + }); + + setIsUpdating(false); + return; + } + + const data = await res.json(); + + if (data) { + addToast( + + {intl.formatMessage(messages.watchlistSuccess, { + title: tv?.name, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + } + + setIsUpdating(false); + setToggleWatchlist((prevState) => !prevState); + }; + + const onClickDeleteWatchlistBtn = async (): Promise => { + setIsUpdating(true); + + const res = await fetch('/api/v1/watchlist/' + tv?.id, { + method: 'DELETE', + }); + + if (!res.ok) { + addToast(intl.formatMessage(messages.watchlistError), { + appearance: 'error', + autoDismiss: true, + }); + + setIsUpdating(false); + return; + } + + if (res.status === 204) { + addToast( + + {intl.formatMessage(messages.watchlistDeleted, { + title: tv?.name, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'info', autoDismiss: true } + ); + setIsUpdating(false); + setToggleWatchlist((prevState) => !prevState); + } + }; + return (
{
+ <> + {toggleWatchlist ? ( + + + + ) : ( + + + + )} + {title} Removed from watchlist successfully!", + "components.MovieDetails.watchlistError": "Something went wrong try again.", + "components.MovieDetails.watchlistSuccess": "{title} added to watchlist successfully!", "components.MovieDetails.watchtrailer": "Watch Trailer", "components.NotificationTypeSelector.adminissuecommentDescription": "Get notified when other users comment on issues.", "components.NotificationTypeSelector.adminissuereopenedDescription": "Get notified when issues are reopened by other users.", @@ -1071,6 +1076,7 @@ "components.TvDetails.Season.somethingwentwrong": "Something went wrong while retrieving season data.", "components.TvDetails.TvCast.fullseriescast": "Full Series Cast", "components.TvDetails.TvCrew.fullseriescrew": "Full Series Crew", + "components.TvDetails.addtowatchlist": "Add To Watchlist", "components.TvDetails.anime": "Anime", "components.TvDetails.cast": "Cast", "components.TvDetails.episodeCount": "{episodeCount, plural, one {# Episode} other {# Episodes}}", @@ -1088,6 +1094,7 @@ "components.TvDetails.play4k": "Play 4K on {mediaServerName}", "components.TvDetails.productioncountries": "Production {countryCount, plural, one {Country} other {Countries}}", "components.TvDetails.recommendations": "Recommendations", + "components.TvDetails.removefromwatchlist": "Remove From Watchlist", "components.TvDetails.reportissue": "Report an Issue", "components.TvDetails.rtaudiencescore": "Rotten Tomatoes Audience Score", "components.TvDetails.rtcriticsscore": "Rotten Tomatoes Tomatometer", @@ -1100,6 +1107,9 @@ "components.TvDetails.streamingproviders": "Currently Streaming On", "components.TvDetails.tmdbuserscore": "TMDB User Score", "components.TvDetails.viewfullcrew": "View Full Crew", + "components.TvDetails.watchlistDeleted": "{title} Removed from watchlist successfully!", + "components.TvDetails.watchlistError": "Something went wrong try again.", + "components.TvDetails.watchlistSuccess": "{title} added to watchlist successfully!", "components.TvDetails.watchtrailer": "Watch Trailer", "components.UserList.accounttype": "Type", "components.UserList.admin": "Admin",