diff --git a/cypress/e2e/discover.cy.ts b/cypress/e2e/discover.cy.ts index a58eb4ae..3489061b 100644 --- a/cypress/e2e/discover.cy.ts +++ b/cypress/e2e/discover.cy.ts @@ -18,7 +18,7 @@ const clickFirstTitleCardInSlider = (sliderTitle: string): void => { describe('Discover', () => { beforeEach(() => { - cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD')); + cy.loginAsAdmin(); }); it('loads a trending item', () => { diff --git a/cypress/e2e/login.cy.ts b/cypress/e2e/login.cy.ts index cc3c0e05..1c955417 100644 --- a/cypress/e2e/login.cy.ts +++ b/cypress/e2e/login.cy.ts @@ -1,12 +1,12 @@ describe('Login Page', () => { it('succesfully logs in as an admin', () => { - cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD')); + cy.loginAsAdmin(); cy.visit('/'); cy.contains('Trending'); }); it('succesfully logs in as a local user', () => { - cy.login(Cypress.env('USER_EMAIL'), Cypress.env('USER_PASSWORD')); + cy.loginAsUser(); cy.visit('/'); cy.contains('Trending'); }); diff --git a/cypress/e2e/movie-details.cy.ts b/cypress/e2e/movie-details.cy.ts index c68e052f..1d3ecf3f 100644 --- a/cypress/e2e/movie-details.cy.ts +++ b/cypress/e2e/movie-details.cy.ts @@ -1,6 +1,6 @@ describe('Movie Details', () => { it('loads a movie page', () => { - cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD')); + cy.loginAsAdmin(); // Try to load minions: rise of gru cy.visit('/movie/438148'); diff --git a/cypress/e2e/settings/general-settings.cy.ts b/cypress/e2e/settings/general-settings.cy.ts index 9fb9b82f..3717f65b 100644 --- a/cypress/e2e/settings/general-settings.cy.ts +++ b/cypress/e2e/settings/general-settings.cy.ts @@ -1,6 +1,6 @@ describe('General Settings', () => { beforeEach(() => { - cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD')); + cy.loginAsAdmin(); }); it('opens the settings page from the home page', () => { diff --git a/cypress/e2e/tv-details.cy.ts b/cypress/e2e/tv-details.cy.ts index 3cb2f8cf..5b4bd049 100644 --- a/cypress/e2e/tv-details.cy.ts +++ b/cypress/e2e/tv-details.cy.ts @@ -1,6 +1,6 @@ describe('TV Details', () => { - it('loads a movie page', () => { - cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD')); + it('loads a tv details page', () => { + cy.loginAsAdmin(); // Try to load stranger things cy.visit('/tv/66732'); @@ -9,4 +9,20 @@ describe('TV Details', () => { 'Stranger Things (2016)' ); }); + + it('shows seasons and expands episodes', () => { + cy.loginAsAdmin(); + + // Try to load stranger things + cy.visit('/tv/66732'); + + // intercept request for season info + cy.intercept('/api/v1/tv/66732/season/4').as('season4'); + + cy.contains('Season 4').should('be.visible').scrollIntoView().click(); + + cy.wait('@season4'); + + cy.contains('Chapter Nine').should('be.visible'); + }); }); diff --git a/cypress/e2e/user/auto-request-settings.cy.ts b/cypress/e2e/user/auto-request-settings.cy.ts index dfc7c672..e7f5727b 100644 --- a/cypress/e2e/user/auto-request-settings.cy.ts +++ b/cypress/e2e/user/auto-request-settings.cy.ts @@ -6,7 +6,7 @@ const visitUserEditPage = (email: string): void => { describe('Auto Request Settings', () => { beforeEach(() => { - cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD')); + cy.loginAsAdmin(); }); it('should not see watchlist sync settings on an account without permissions', () => { diff --git a/cypress/e2e/user/profile.cy.ts b/cypress/e2e/user/profile.cy.ts index 1f531b41..9cc38d88 100644 --- a/cypress/e2e/user/profile.cy.ts +++ b/cypress/e2e/user/profile.cy.ts @@ -1,6 +1,6 @@ describe('User Profile', () => { beforeEach(() => { - cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD')); + cy.loginAsAdmin(); }); it('opens user profile page from the home page', () => { diff --git a/cypress/e2e/user/user-list.cy.ts b/cypress/e2e/user/user-list.cy.ts index f3dd86ef..8239e289 100644 --- a/cypress/e2e/user/user-list.cy.ts +++ b/cypress/e2e/user/user-list.cy.ts @@ -6,7 +6,7 @@ const testUser = { describe('User List', () => { beforeEach(() => { - cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD')); + cy.loginAsAdmin(); }); it('opens the user list from the home page', () => { diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index a4c3cfcd..e1afafe7 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -24,3 +24,11 @@ Cypress.Commands.add('login', (email, password) => { } ); }); + +Cypress.Commands.add('loginAsAdmin', () => { + cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD')); +}); + +Cypress.Commands.add('loginAsUser', () => { + cy.login(Cypress.env('USER_EMAIL'), Cypress.env('USER_PASSWORD')); +}); diff --git a/cypress/support/index.ts b/cypress/support/index.ts index 72eb11bd..85706761 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -5,6 +5,8 @@ declare global { namespace Cypress { interface Chainable { login(email?: string, password?: string): Chainable; + loginAsAdmin(): Chainable; + loginAsUser(): Chainable; } } } diff --git a/package.json b/package.json index 5e665ff5..1816b1c0 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@formatjs/intl-displaynames": "6.0.3", "@formatjs/intl-locale": "3.0.3", "@formatjs/intl-pluralrules": "5.0.3", + "@formatjs/intl-utils": "^3.8.4", "@headlessui/react": "1.6.6", "@heroicons/react": "1.0.6", "@supercharge/request-ip": "1.2.0", diff --git a/src/components/AirDateBadge/index.tsx b/src/components/AirDateBadge/index.tsx new file mode 100644 index 00000000..f6510e98 --- /dev/null +++ b/src/components/AirDateBadge/index.tsx @@ -0,0 +1,62 @@ +import Badge from '@app/components/Common/Badge'; +import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl'; + +const messages = defineMessages({ + airedrelative: 'Aired {relativeTime}', + airsrelative: 'Airs {relativeTime}', +}); + +type AirDateBadgeProps = { + airDate: string; +}; + +const AirDateBadge = ({ airDate }: AirDateBadgeProps) => { + const WEEK = 1000 * 60 * 60 * 24 * 8; + const intl = useIntl(); + const dAirDate = new Date(airDate); + const nowDate = new Date(); + const alreadyAired = dAirDate.getTime() < nowDate.getTime(); + + const compareWeek = new Date( + alreadyAired ? Date.now() - WEEK : Date.now() + WEEK + ); + + let showRelative = false; + + if ( + (alreadyAired && dAirDate.getTime() > compareWeek.getTime()) || + (!alreadyAired && dAirDate.getTime() < compareWeek.getTime()) + ) { + showRelative = true; + } + + return ( +
+ + {intl.formatDate(dAirDate, { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + + {showRelative && ( + + {intl.formatMessage( + alreadyAired ? messages.airedrelative : messages.airsrelative, + { + relativeTime: ( + + ), + } + )} + + )} +
+ ); +}; + +export default AirDateBadge; diff --git a/src/components/Common/Badge/index.tsx b/src/components/Common/Badge/index.tsx index 0a138891..5619eaef 100644 --- a/src/components/Common/Badge/index.tsx +++ b/src/components/Common/Badge/index.tsx @@ -1,7 +1,14 @@ import Link from 'next/link'; interface BadgeProps { - badgeType?: 'default' | 'primary' | 'danger' | 'warning' | 'success'; + badgeType?: + | 'default' + | 'primary' + | 'danger' + | 'warning' + | 'success' + | 'dark' + | 'light'; className?: string; href?: string; children: React.ReactNode; @@ -42,6 +49,18 @@ const Badge = ({ badgeStyle.push('hover:bg-green-400'); } break; + case 'dark': + badgeStyle.push('bg-gray-900 !text-gray-400'); + if (href) { + badgeStyle.push('hover:bg-gray-800'); + } + break; + case 'light': + badgeStyle.push('bg-gray-700 !text-gray-300'); + if (href) { + badgeStyle.push('hover:bg-gray-600'); + } + break; default: badgeStyle.push('bg-indigo-500 !text-indigo-100'); if (href) { diff --git a/src/components/TvDetails/Season/index.tsx b/src/components/TvDetails/Season/index.tsx new file mode 100644 index 00000000..a8f45764 --- /dev/null +++ b/src/components/TvDetails/Season/index.tsx @@ -0,0 +1,62 @@ +import AirDateBadge from '@app/components/AirDateBadge'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import type { SeasonWithEpisodes } from '@server/models/Tv'; +import { defineMessages, useIntl } from 'react-intl'; +import useSWR from 'swr'; + +const messages = defineMessages({ + somethingwentwrong: 'Something went wrong while retrieving season data.', +}); + +type SeasonProps = { + seasonNumber: number; + tvId: number; +}; + +const Season = ({ seasonNumber, tvId }: SeasonProps) => { + const intl = useIntl(); + const { data, error } = useSWR( + `/api/v1/tv/${tvId}/season/${seasonNumber}` + ); + + if (!data && !error) { + return ; + } + + if (!data) { + return
{intl.formatMessage(messages.somethingwentwrong)}
; + } + + return ( +
+ {data.episodes + .slice() + .reverse() + .map((episode) => { + return ( +
+
+
+

{episode.name}

+ +
+ {episode.overview &&

{episode.overview}

} +
+ {episode.stillPath && ( + + )} +
+ ); + })} +
+ ); +}; + +export default Season; diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index 823be31e..35b995d8 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/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 TmdbLogo from '@app/assets/tmdb_logo.svg'; +import Badge from '@app/components/Common/Badge'; import Button from '@app/components/Common/Button'; import CachedImage from '@app/components/Common/CachedImage'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; @@ -19,12 +20,14 @@ import RequestButton from '@app/components/RequestButton'; import RequestModal from '@app/components/RequestModal'; import Slider from '@app/components/Slider'; import StatusBadge from '@app/components/StatusBadge'; +import Season from '@app/components/TvDetails/Season'; import useLocale from '@app/hooks/useLocale'; import useSettings from '@app/hooks/useSettings'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import Error from '@app/pages/_error'; import { sortCrewPriority } from '@app/utils/creditHelpers'; +import { Disclosure, Transition } from '@headlessui/react'; import { ArrowCircleRightIcon, CogIcon, @@ -32,10 +35,11 @@ import { FilmIcon, PlayIcon, } from '@heroicons/react/outline'; +import { ChevronUpIcon } from '@heroicons/react/solid'; import type { RTRating } from '@server/api/rottentomatoes'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import { IssueStatus } from '@server/constants/issue'; -import { MediaStatus } from '@server/constants/media'; +import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; import type { Crew } from '@server/models/common'; import type { TvDetails as TvDetailsType } from '@server/models/Tv'; import { hasFlag } from 'country-flag-icons'; @@ -71,6 +75,10 @@ const messages = defineMessages({ 'Production {countryCount, plural, one {Country} other {Countries}}', reportissue: 'Report an Issue', manageseries: 'Manage Series', + seasonstitle: 'Seasons', + episodeCount: '{episodeCount, plural, one {# Episode} other {# Episodes}}', + seasonnumber: 'Season {seasonNumber}', + status4k: '4K {status}', }); interface TvDetailsProps { @@ -476,6 +484,174 @@ const TvDetails = ({ tv }: TvDetailsProps) => { )} +

{intl.formatMessage(messages.seasonstitle)}

+
+ {data.seasons + .slice() + .reverse() + .filter((season) => season.seasonNumber !== 0) + .map((season) => { + const show4k = + settings.currentSettings.series4kEnabled && + hasPermission( + [ + Permission.MANAGE_REQUESTS, + Permission.REQUEST_4K, + Permission.REQUEST_4K_TV, + ], + { + type: 'or', + } + ); + const mSeason = (data.mediaInfo?.seasons ?? []).find( + (s) => + season.seasonNumber === s.seasonNumber && + s.status !== MediaStatus.UNKNOWN + ); + const mSeason4k = (data.mediaInfo?.seasons ?? []).find( + (s) => + season.seasonNumber === s.seasonNumber && + s.status4k !== MediaStatus.UNKNOWN + ); + const request = (data.mediaInfo?.requests ?? []).find( + (r) => + !!r.seasons.find( + (s) => s.seasonNumber === season.seasonNumber + ) && !r.is4k + ); + const request4k = (data.mediaInfo?.requests ?? []).find( + (r) => + !!r.seasons.find( + (s) => s.seasonNumber === season.seasonNumber + ) && r.is4k + ); + + return ( + + {({ open }) => ( + <> + +
+ + {intl.formatMessage(messages.seasonnumber, { + seasonNumber: season.seasonNumber, + })} + + + {intl.formatMessage(messages.episodeCount, { + episodeCount: season.episodeCount, + })} + +
+ {((!mSeason && + request?.status === MediaRequestStatus.APPROVED) || + mSeason?.status === MediaStatus.PROCESSING) && ( + + {intl.formatMessage(globalMessages.requested)} + + )} + {((!mSeason && + request?.status === MediaRequestStatus.PENDING) || + mSeason?.status === MediaStatus.PENDING) && ( + + {intl.formatMessage(globalMessages.pending)} + + )} + {mSeason?.status === + MediaStatus.PARTIALLY_AVAILABLE && ( + + {intl.formatMessage( + globalMessages.partiallyavailable + )} + + )} + {mSeason?.status === MediaStatus.AVAILABLE && ( + + {intl.formatMessage(globalMessages.available)} + + )} + {((!mSeason4k && + request4k?.status === + MediaRequestStatus.APPROVED) || + mSeason4k?.status4k === MediaStatus.PROCESSING) && + show4k && ( + + {intl.formatMessage(messages.status4k, { + status: intl.formatMessage( + globalMessages.requested + ), + })} + + )} + {((!mSeason4k && + request4k?.status === MediaRequestStatus.PENDING) || + mSeason?.status4k === MediaStatus.PENDING) && + show4k && ( + + {intl.formatMessage(messages.status4k, { + status: intl.formatMessage( + globalMessages.pending + ), + })} + + )} + {mSeason4k?.status4k === + MediaStatus.PARTIALLY_AVAILABLE && + show4k && ( + + {intl.formatMessage(messages.status4k, { + status: intl.formatMessage( + globalMessages.partiallyavailable + ), + })} + + )} + {mSeason4k?.status4k === MediaStatus.AVAILABLE && + show4k && ( + + {intl.formatMessage(messages.status4k, { + status: intl.formatMessage( + globalMessages.available + ), + })} + + )} + +
+ + + + + + + )} +
+ ); + })} +
diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 1cc403ce..dc8b588c 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1,4 +1,6 @@ { + "components.AirDateBadge.airedrelative": "Aired {relativeTime}", + "components.AirDateBadge.airsrelative": "Airs {relativeTime}", "components.AppDataWarning.dockerVolumeMissingDescription": "The {appDataPath} volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.", "components.CollectionDetails.numberofmovies": "{count} Movies", "components.CollectionDetails.overview": "Overview", @@ -858,10 +860,12 @@ "components.TitleCard.mediaerror": "{mediaType} Not Found", "components.TitleCard.tmdbid": "TMDB ID", "components.TitleCard.tvdbid": "TheTVDB ID", + "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.anime": "Anime", "components.TvDetails.cast": "Cast", + "components.TvDetails.episodeCount": "{episodeCount, plural, one {# Episode} other {# Episodes}}", "components.TvDetails.episodeRuntime": "Episode Runtime", "components.TvDetails.episodeRuntimeMinutes": "{runtime} minutes", "components.TvDetails.firstAirDate": "First Air Date", @@ -877,9 +881,12 @@ "components.TvDetails.productioncountries": "Production {countryCount, plural, one {Country} other {Countries}}", "components.TvDetails.recommendations": "Recommendations", "components.TvDetails.reportissue": "Report an Issue", + "components.TvDetails.seasonnumber": "Season {seasonNumber}", "components.TvDetails.seasons": "{seasonCount, plural, one {# Season} other {# Seasons}}", + "components.TvDetails.seasonstitle": "Seasons", "components.TvDetails.showtype": "Series Type", "components.TvDetails.similar": "Similar Series", + "components.TvDetails.status4k": "4K {status}", "components.TvDetails.streamingproviders": "Currently Streaming On", "components.TvDetails.viewfullcrew": "View Full Crew", "components.TvDetails.watchtrailer": "Watch Trailer", diff --git a/yarn.lock b/yarn.lock index 03f3d4af..d59f4cb5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1750,6 +1750,13 @@ "@formatjs/intl-localematcher" "0.2.28" tslib "2.4.0" +"@formatjs/intl-utils@^3.8.4": + version "3.8.4" + resolved "https://registry.yarnpkg.com/@formatjs/intl-utils/-/intl-utils-3.8.4.tgz#291baac91001db428fc3275c515a3e40fbe95945" + integrity sha512-j5C6NyfKevIxsfLK8KwO1C0vvP7k1+h4A9cFpc+cr6mEwCc1sPkr17dzh0Ke6k9U5pQccAQoXdcNBl3IYa4+ZQ== + dependencies: + emojis-list "^3.0.0" + "@formatjs/intl@2.3.1": version "2.3.1" resolved "https://registry.yarnpkg.com/@formatjs/intl/-/intl-2.3.1.tgz#eccd6d03e4db18c256181f235b1d0a7f7aaebf5a" @@ -5435,6 +5442,11 @@ emoji-regex@^9.2.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== +emojis-list@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" + integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== + enabled@2.0.x: version "2.0.0" resolved "https://registry.yarnpkg.com/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2"