feat: add tagline, episode runtime, genres list to media details & clean/refactor CSS into globals (#1160)

pull/1179/head
TheCatLady 3 years ago committed by GitHub
parent 3d6b343413
commit 2f2e00237d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -904,6 +904,8 @@ components:
$ref: '#/components/schemas/Season'
status:
type: string
tagline:
type: string
type:
type: string
voteAverage:
@ -4737,6 +4739,12 @@ paths:
description: Returns a list of genres in a JSON array.
tags:
- tmdb
parameters:
- in: query
name: language
schema:
type: string
example: en
responses:
'200':
description: Results
@ -4759,6 +4767,12 @@ paths:
description: Returns a list of genres in a JSON array.
tags:
- tmdb
parameters:
- in: query
name: language
schema:
type: string
example: en
responses:
'200':
description: Results

@ -254,6 +254,7 @@ export interface TmdbTvDetails {
}[];
seasons: TmdbTvSeasonResult[];
status: string;
tagline?: string;
type: string;
vote_average: number;
vote_count: number;

@ -91,6 +91,7 @@ export interface TvDetails {
spokenLanguages: SpokenLanguage[];
seasons: Season[];
status: string;
tagline?: string;
type: string;
voteAverage: number;
voteCount: number;
@ -174,6 +175,7 @@ export const mapTvDetails = (
originCountry: show.origin_country,
originalLanguage: show.original_language,
originalName: show.original_name,
tagline: show.tagline,
overview: show.overview,
popularity: show.popularity,
productionCompanies: show.production_companies.map((company) => ({

@ -95,7 +95,9 @@ router.get<{ id: string }>('/network/:id', async (req, res) => {
router.get('/genres/movie', isAuthenticated(), async (req, res) => {
const tmdb = new TheMovieDb();
const genres = await tmdb.getMovieGenres();
const genres = await tmdb.getMovieGenres({
language: req.query.language as string,
});
return res.status(200).json(genres);
});
@ -103,7 +105,9 @@ router.get('/genres/movie', isAuthenticated(), async (req, res) => {
router.get('/genres/tv', isAuthenticated(), async (req, res) => {
const tmdb = new TheMovieDb();
const genres = await tmdb.getTvGenres();
const genres = await tmdb.getTvGenres({
language: req.query.language as string,
});
return res.status(200).json(genres);
});

@ -19,12 +19,14 @@ import Transition from '../Transition';
import PageTitle from '../Common/PageTitle';
import { useUser, Permission } from '../../hooks/useUser';
import useSettings from '../../hooks/useSettings';
import Link from 'next/link';
import { uniq } from 'lodash';
const messages = defineMessages({
overviewunavailable: 'Overview unavailable.',
overview: 'Overview',
movies: 'Movies',
numberofmovies: 'Number of Movies: {count}',
numberofmovies: '{count} Movies',
requesting: 'Requesting…',
request: 'Request',
requestcollection: 'Request Collection',
@ -62,6 +64,10 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
}
);
const { data: genres } = useSWR<{ id: number; name: string }[]>(
`/api/v1/genres/movie?language=${locale}`
);
if (!data && !error) {
return <LoadingSpinner />;
}
@ -105,6 +111,17 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
collectionStatus4k = MediaStatus.PARTIALLY_AVAILABLE;
}
const hasRequestable =
data.parts.filter(
(part) => !part.mediaInfo || part.mediaInfo.status === MediaStatus.UNKNOWN
).length > 0;
const hasRequestable4k =
data.parts.filter(
(part) =>
!part.mediaInfo || part.mediaInfo.status4k === MediaStatus.UNKNOWN
).length > 0;
const requestableParts = data.parts.filter(
(part) =>
!part.mediaInfo ||
@ -147,9 +164,43 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
}
};
const collectionAttributes: React.ReactNode[] = [];
collectionAttributes.push(
intl.formatMessage(messages.numberofmovies, {
count: data.parts.length,
})
);
if (genres && data.parts.some((part) => part.genreIds.length)) {
collectionAttributes.push(
uniq(
data.parts.reduce(
(genresList: number[], curr) => genresList.concat(curr.genreIds),
[]
)
)
.map((genreId) => (
<Link
href={`/discover/movies/genre/${genreId}`}
key={`genre-${genreId}`}
>
<a className="hover:underline">
{genres.find((g) => g.id === genreId)?.name}
</a>
</Link>
))
.reduce((prev, curr) => (
<>
{prev}, {curr}
</>
))
);
}
return (
<div
className="px-4 pt-16 -mx-4 -mt-16 bg-center bg-cover"
className="media-page"
style={{
height: 493,
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
@ -216,24 +267,20 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
</ul>
</Modal>
</Transition>
<div className="flex flex-col items-center pt-4 lg:flex-row lg:items-end">
<div className="lg:mr-4">
<img
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
alt=""
className="w-32 rounded shadow md:rounded-lg md:shadow-2xl md:w-44 lg:w-52"
/>
</div>
<div className="flex flex-col flex-1 mt-4 text-center text-white lg:mr-4 lg:mt-0 lg:text-left">
<div className="mb-2 space-x-2">
<span className="ml-2 lg:ml-0">
<StatusBadge
status={collectionStatus}
inProgress={data.parts.some(
(part) => (part.mediaInfo?.downloadStatus ?? []).length > 0
)}
/>
</span>
<div className="media-header">
<img
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
alt=""
className="media-poster"
/>
<div className="media-title">
<div className="media-status">
<StatusBadge
status={collectionStatus}
inProgress={data.parts.some(
(part) => (part.mediaInfo?.downloadStatus ?? []).length > 0
)}
/>
{settings.currentSettings.movie4kEnabled &&
hasPermission(
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
@ -241,43 +288,83 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
type: 'or',
}
) && (
<span>
<StatusBadge
status={collectionStatus4k}
is4k
inProgress={data.parts.some(
(part) =>
(part.mediaInfo?.downloadStatus4k ?? []).length > 0
)}
/>
</span>
<StatusBadge
status={collectionStatus4k}
is4k
inProgress={data.parts.some(
(part) =>
(part.mediaInfo?.downloadStatus4k ?? []).length > 0
)}
/>
)}
</div>
<h1 className="text-2xl md:text-4xl">{data.name}</h1>
<span className="mt-1 text-xs lg:text-base lg:mt-0">
{intl.formatMessage(messages.numberofmovies, {
count: data.parts.length,
})}
<h1>{data.name}</h1>
<span className="media-attributes">
{collectionAttributes.length > 0 &&
collectionAttributes
.map((t, k) => <span key={k}>{t}</span>)
.reduce((prev, curr) => (
<>
{prev} | {curr}
</>
))}
</span>
</div>
<div className="relative z-10 flex flex-wrap justify-center flex-shrink-0 mt-4 sm:justify-end sm:flex-nowrap lg:mt-0">
<div className="media-actions">
{hasPermission(Permission.REQUEST) &&
(collectionStatus !== MediaStatus.AVAILABLE ||
(hasRequestable ||
(settings.currentSettings.movie4kEnabled &&
hasPermission(
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
{ type: 'or' }
) &&
collectionStatus4k !== MediaStatus.AVAILABLE)) && (
<div className="mb-3 sm:mb-0">
<ButtonWithDropdown
buttonType="primary"
onClick={() => {
setRequestModal(true);
setIs4k(collectionStatus === MediaStatus.AVAILABLE);
}}
text={
<>
hasRequestable4k)) && (
<ButtonWithDropdown
buttonType="primary"
onClick={() => {
setRequestModal(true);
setIs4k(!hasRequestable);
}}
text={
<>
<svg
className="w-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
<span>
{intl.formatMessage(
hasRequestable
? messages.requestcollection
: messages.requestcollection4k
)}
</span>
</>
}
>
{settings.currentSettings.movie4kEnabled &&
hasPermission(
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
{ type: 'or' }
) &&
hasRequestable &&
hasRequestable4k && (
<ButtonWithDropdown.Item
buttonType="primary"
onClick={() => {
setRequestModal(true);
setIs4k(true);
}}
>
<svg
className="w-4 mr-1"
fill="none"
@ -293,70 +380,27 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
/>
</svg>
<span>
{intl.formatMessage(
collectionStatus === MediaStatus.AVAILABLE
? messages.requestcollection4k
: messages.requestcollection
)}
{intl.formatMessage(messages.requestcollection4k)}
</span>
</>
}
>
{settings.currentSettings.movie4kEnabled &&
hasPermission(
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
{ type: 'or' }
) &&
collectionStatus !== MediaStatus.AVAILABLE &&
collectionStatus4k !== MediaStatus.AVAILABLE && (
<ButtonWithDropdown.Item
buttonType="primary"
onClick={() => {
setRequestModal(true);
setIs4k(true);
}}
>
<svg
className="w-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
<span>
{intl.formatMessage(messages.requestcollection4k)}
</span>
</ButtonWithDropdown.Item>
)}
</ButtonWithDropdown>
</div>
</ButtonWithDropdown.Item>
)}
</ButtonWithDropdown>
)}
</div>
</div>
<div className="flex flex-col pt-8 pb-4 text-white md:flex-row">
<div className="flex-1 md:mr-8">
<h2 className="text-xl md:text-2xl">
{intl.formatMessage(messages.overview)}
</h2>
<p className="pt-2 text-sm md:text-base">
<div className="media-overview">
<div className="flex-1">
<h2>{intl.formatMessage(messages.overview)}</h2>
<p>
{data.overview
? data.overview
: intl.formatMessage(messages.overviewunavailable)}
</p>
</div>
</div>
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<div className="inline-flex items-center text-xl leading-7 text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>{intl.formatMessage(messages.movies)}</span>
</div>
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.movies)}</span>
</div>
</div>
<Slider

@ -45,37 +45,37 @@ function Button<P extends ElementTypes = 'button'>(
ref?: React.Ref<Element<P>>
): JSX.Element {
const buttonStyle = [
'inline-flex items-center justify-center border border-transparent leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer',
'inline-flex items-center justify-center border border-transparent leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer disabled:opacity-50',
];
switch (buttonType) {
case 'primary':
buttonStyle.push(
'text-white bg-indigo-600 hover:bg-indigo-500 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 disabled:opacity-50'
'text-white bg-indigo-600 border-indigo-600 hover:bg-indigo-500 hover:border-indigo-500 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 active:border-indigo-700'
);
break;
case 'danger':
buttonStyle.push(
'text-white bg-red-600 hover:bg-red-500 focus:border-red-700 focus:ring-red active:bg-red-700 disabled:opacity-50'
'text-white bg-red-600 border-red-600 hover:bg-red-500 hover:border-red-500 focus:border-red-700 focus:ring-red active:bg-red-700 active:border-red-700'
);
break;
case 'warning':
buttonStyle.push(
'text-white bg-yellow-500 hover:bg-yellow-400 focus:border-yellow-700 focus:ring-yellow active:bg-yellow-700 disabled:opacity-50'
'text-white bg-yellow-500 border-yellow-500 hover:bg-yellow-400 hover:border-yellow-400 focus:border-yellow-700 focus:ring-yellow active:bg-yellow-700 active:border-yellow-700'
);
break;
case 'success':
buttonStyle.push(
'text-white bg-green-400 hover:bg-green-300 focus:border-green-700 focus:ring-green active:bg-green-700 disabled:opacity-50'
'text-white bg-green-400 border-green-400 hover:bg-green-300 hover:border-green-300 focus:border-green-700 focus:ring-green active:bg-green-700 active:border-green-700'
);
break;
case 'ghost':
buttonStyle.push(
'text-white bg-transaprent border border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100 disabled:opacity-50'
'text-white bg-transaprent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'
);
break;
default:
buttonStyle.push(
'leading-5 font-medium rounded-md text-gray-200 bg-gray-500 hover:bg-gray-400 group-hover:bg-gray-400 hover:text-white group-hover:text-white focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-400 disabled:opacity-50'
'text-gray-200 bg-gray-500 border-gray-500 hover:text-white hover:bg-gray-400 hover:border-gray-400 group-hover:text-white group-hover:bg-gray-400 group-hover:border-gray-400 focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-400 active:border-gray-400'
);
}

@ -59,24 +59,23 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
useClickOutside(buttonRef, () => setIsOpen(false));
const styleClasses = {
mainButtonClasses: '',
dropdownSideButtonClasses: '',
mainButtonClasses: 'text-white border',
dropdownSideButtonClasses: 'border',
dropdownClasses: '',
};
switch (buttonType) {
case 'ghost':
styleClasses.mainButtonClasses =
'text-white bg-transparent border border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
styleClasses.dropdownSideButtonClasses =
'bg-transparent border border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
styleClasses.mainButtonClasses +=
' bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
styleClasses.dropdownSideButtonClasses = styleClasses.mainButtonClasses;
styleClasses.dropdownClasses = 'bg-gray-700';
break;
default:
styleClasses.mainButtonClasses =
'text-white bg-indigo-600 hover:text-white hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue';
styleClasses.dropdownSideButtonClasses =
'bg-indigo-700 border border-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue';
styleClasses.mainButtonClasses +=
' bg-indigo-600 border-indigo-600 hover:bg-indigo-500 hover:border-indigo-500 active:bg-indigo-700 active:border-indigo-700 focus:ring-blue';
styleClasses.dropdownSideButtonClasses +=
' bg-indigo-700 border-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue';
styleClasses.dropdownClasses = 'bg-indigo-600';
}

@ -24,11 +24,11 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
plexUrl,
}) => {
return (
<div className="flex items-center justify-end">
<div className="flex items-center justify-center w-full space-x-5">
{plexUrl && (
<a
href={plexUrl}
className="w-8 mx-2 transition duration-300 opacity-50 hover:opacity-100"
className="w-12 transition duration-300 opacity-50 hover:opacity-100"
target="_blank"
rel="noreferrer"
>
@ -38,7 +38,7 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
{tmdbId && (
<a
href={`https://www.themoviedb.org/${mediaType}/${tmdbId}`}
className="w-8 mx-2 transition duration-300 opacity-50 hover:opacity-100"
className="w-8 transition duration-300 opacity-50 hover:opacity-100"
target="_blank"
rel="noreferrer"
>
@ -48,7 +48,7 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
{tvdbId && mediaType === MediaType.TV && (
<a
href={`http://www.thetvdb.com/?tab=series&id=${tvdbId}`}
className="w-8 mx-2 transition duration-300 opacity-50 hover:opacity-100"
className="transition duration-300 opacity-50 w-9 hover:opacity-100"
target="_blank"
rel="noreferrer"
>
@ -58,7 +58,7 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
{imdbId && (
<a
href={`https://www.imdb.com/title/${imdbId}`}
className="w-8 mx-2 transition duration-300 opacity-50 hover:opacity-100"
className="w-8 transition duration-300 opacity-50 hover:opacity-100"
target="_blank"
rel="noreferrer"
>
@ -68,7 +68,7 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
{rtUrl && (
<a
href={`${rtUrl}`}
className="mx-2 transition duration-300 opacity-50 w-14 hover:opacity-100"
className="transition duration-300 opacity-50 w-14 hover:opacity-100"
target="_blank"
rel="noreferrer"
>

@ -1,10 +1,5 @@
import React, { useState, useContext, useMemo } from 'react';
import {
defineMessages,
FormattedNumber,
FormattedDate,
useIntl,
} from 'react-intl';
import { defineMessages, FormattedNumber, useIntl } from 'react-intl';
import type { MovieDetails as MovieDetailsType } from '../../../server/models/Movie';
import useSWR from 'swr';
import { useRouter } from 'next/router';
@ -205,7 +200,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
return (
<div
className="px-4 pt-16 -mx-4 -mt-16 bg-center bg-cover"
className="media-page"
style={{
height: 493,
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
@ -384,27 +379,23 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</div>
)}
</SlideOver>
<div className="flex flex-col items-center pt-4 lg:flex-row lg:items-end">
<div className="lg:mr-4">
<img
src={
data.posterPath
? `//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
: '/images/overseerr_poster_not_found.png'
}
alt=""
className="w-32 rounded shadow md:rounded-lg md:shadow-2xl md:w-44 lg:w-52"
/>
</div>
<div className="flex flex-col flex-1 mt-4 text-center text-white lg:mr-4 lg:mt-0 lg:text-left">
<div className="mb-2 space-x-2">
<span className="ml-2 lg:ml-0">
<StatusBadge
status={data.mediaInfo?.status}
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
plexUrl={data.mediaInfo?.plexUrl}
/>
</span>
<div className="media-header">
<img
src={
data.posterPath
? `//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
: '/images/overseerr_poster_not_found.png'
}
alt=""
className="media-poster"
/>
<div className="media-title">
<div className="media-status">
<StatusBadge
status={data.mediaInfo?.status}
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
plexUrl={data.mediaInfo?.plexUrl}
/>
{settings.currentSettings.movie4kEnabled &&
hasPermission(
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
@ -412,25 +403,25 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
type: 'or',
}
) && (
<span>
<StatusBadge
status={data.mediaInfo?.status4k}
is4k
inProgress={
(data.mediaInfo?.downloadStatus4k ?? []).length > 0
}
plexUrl4k={data.mediaInfo?.plexUrl4k}
/>
</span>
<StatusBadge
status={data.mediaInfo?.status4k}
is4k
inProgress={
(data.mediaInfo?.downloadStatus4k ?? []).length > 0
}
plexUrl4k={data.mediaInfo?.plexUrl4k}
/>
)}
</div>
<h1 className="text-2xl lg:text-4xl">
<h1>
{data.title}{' '}
{data.releaseDate && (
<span className="text-2xl">({data.releaseDate.slice(0, 4)})</span>
<span className="media-year">
({data.releaseDate.slice(0, 4)})
</span>
)}
</h1>
<span className="mt-1 text-xs lg:text-base lg:mt-0">
<span className="media-attributes">
{movieAttributes.length > 0 &&
movieAttributes
.map((t, k) => <span key={k}>{t}</span>)
@ -441,27 +432,23 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
))}
</span>
</div>
<div className="relative z-10 flex flex-wrap justify-center flex-shrink-0 mt-4 sm:justify-end sm:flex-nowrap lg:mt-0">
<div className="mb-3 sm:mb-0">
<PlayButton links={mediaLinks} />
</div>
<div className="mb-3 sm:mb-0">
<RequestButton
mediaType="movie"
media={data.mediaInfo}
tmdbId={data.id}
onUpdate={() => revalidate()}
/>
</div>
<div className="media-actions">
<PlayButton links={mediaLinks} />
<RequestButton
mediaType="movie"
media={data.mediaInfo}
tmdbId={data.id}
onUpdate={() => revalidate()}
/>
{hasPermission(Permission.MANAGE_REQUESTS) && (
<Button
buttonType="default"
className="mb-3 ml-2 first:ml-0 sm:mb-0"
className="ml-2 first:ml-0"
onClick={() => setShowManager(true)}
>
<svg
className="w-5"
style={{ height: 20 }}
style={{ height: 18 }}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -484,27 +471,21 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
)}
</div>
</div>
<div className="flex flex-col pt-8 pb-4 text-white md:flex-row">
<div className="flex-1 md:mr-8">
<h2 className="text-xl md:text-2xl">
{intl.formatMessage(messages.overview)}
</h2>
<p className="pt-2 text-sm md:text-base">
<div className="media-overview">
<div className="media-overview-left">
<div className="tagline">{data.tagline}</div>
<h2>{intl.formatMessage(messages.overview)}</h2>
<p>
{data.overview
? data.overview
: intl.formatMessage(messages.overviewunavailable)}
</p>
<ul className="grid grid-cols-2 gap-6 mt-6 sm:grid-cols-3">
<ul className="media-crew">
{sortedCrew.slice(0, 6).map((person) => (
<li
className="flex flex-col col-span-1"
key={`crew-${person.job}-${person.id}`}
>
<span className="font-bold">{person.job}</span>
<li key={`crew-${person.job}-${person.id}`}>
<span>{person.job}</span>
<Link href={`/person/${person.id}`}>
<a className="text-gray-400 transition duration-300 hover:text-underline hover:text-gray-100">
{person.name}
</a>
<a className="crew-name">{person.name}</a>
</Link>
</li>
))}
@ -533,7 +514,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</div>
)}
</div>
<div className="w-full mt-8 md:w-80 md:mt-0">
<div className="media-overview-right">
{data.collection && (
<div className="mb-6">
<Link href={`/collection/${data.collection.id}`}>
@ -555,80 +536,65 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</Link>
</div>
)}
<div className="bg-gray-900 border border-gray-800 rounded-lg shadow">
<div className="media-facts">
{(!!data.voteCount ||
(ratingData?.criticsRating && !!ratingData?.criticsScore) ||
(ratingData?.audienceRating && !!ratingData?.audienceScore)) && (
<div className="flex items-center justify-center px-4 py-2 border-b border-gray-800 last:border-b-0">
<div className="media-ratings">
{ratingData?.criticsRating && !!ratingData?.criticsScore && (
<>
<span className="text-sm">
<span className="media-rating">
{ratingData.criticsRating === 'Rotten' ? (
<RTRotten className="w-6 mr-1" />
) : (
<RTFresh className="w-6 mr-1" />
)}
</span>
<span className="mr-4 text-sm text-gray-400 last:mr-0">
{ratingData.criticsScore}%
</span>
</>
)}
{ratingData?.audienceRating && !!ratingData?.audienceScore && (
<>
<span className="text-sm">
<span className="media-rating">
{ratingData.audienceRating === 'Spilled' ? (
<RTAudRotten className="w-6 mr-1" />
) : (
<RTAudFresh className="w-6 mr-1" />
)}
</span>
<span className="mr-4 text-sm text-gray-400 last:mr-0">
{ratingData.audienceScore}%
</span>
</>
)}
{!!data.voteCount && (
<>
<span className="text-sm">
<span className="media-rating">
<TmdbLogo className="w-6 mr-2" />
</span>
<span className="text-sm text-gray-400">
{data.voteAverage}/10
</span>
</>
)}
</div>
)}
<div className="media-fact">
<span>{intl.formatMessage(messages.status)}</span>
<span className="media-fact-value">{data.status}</span>
</div>
{data.releaseDate && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
{intl.formatMessage(messages.releasedate)}
</span>
<span className="flex-1 text-sm text-right text-gray-400">
<FormattedDate
value={new Date(data.releaseDate)}
year="numeric"
month="long"
day="numeric"
/>
<div className="media-fact">
<span>{intl.formatMessage(messages.releasedate)}</span>
<span className="media-fact-value">
{intl.formatDate(data.releaseDate, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</span>
</div>
)}
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
{intl.formatMessage(messages.status)}
</span>
<span className="flex-1 text-sm text-right text-gray-400">
{data.status}
</span>
</div>
{data.revenue > 0 && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
{intl.formatMessage(messages.revenue)}
</span>
<span className="flex-1 text-sm text-right text-gray-400">
<div className="media-fact">
<span>{intl.formatMessage(messages.revenue)}</span>
<span className="media-fact-value">
<FormattedNumber
currency="USD"
style="currency"
@ -638,11 +604,9 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</div>
)}
{data.budget > 0 && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
{intl.formatMessage(messages.budget)}
</span>
<span className="flex-1 text-sm text-right text-gray-400">
<div className="media-fact">
<span>{intl.formatMessage(messages.budget)}</span>
<span className="media-fact-value">
<FormattedNumber
currency="USD"
style="currency"
@ -652,11 +616,9 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</div>
)}
{data.originalLanguage && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
{intl.formatMessage(messages.originallanguage)}
</span>
<span className="flex-1 text-sm text-right text-gray-400">
<div className="media-fact">
<span>{intl.formatMessage(messages.originallanguage)}</span>
<span className="media-fact-value">
<Link
href={`/discover/movies/language/${data.originalLanguage}`}
>
@ -674,13 +636,13 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</div>
)}
{data.productionCompanies.length > 0 && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
<div className="media-fact">
<span>
{intl.formatMessage(messages.studio, {
studioCount: data.productionCompanies.length,
})}
</span>
<span className="flex-1 text-sm text-right text-gray-400">
<span className="media-fact-value">
{data.productionCompanies.map((s) => {
return (
<Link
@ -694,43 +656,41 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</span>
</div>
)}
</div>
<div className="mt-4">
<ExternalLinkBlock
mediaType="movie"
tmdbId={data.id}
tvdbId={data.externalIds.tvdbId}
imdbId={data.externalIds.imdbId}
rtUrl={ratingData?.url}
plexUrl={data.mediaInfo?.plexUrl ?? data.mediaInfo?.plexUrl4k}
/>
<div className="media-fact">
<ExternalLinkBlock
mediaType="movie"
tmdbId={data.id}
tvdbId={data.externalIds.tvdbId}
imdbId={data.externalIds.imdbId}
rtUrl={ratingData?.url}
plexUrl={data.mediaInfo?.plexUrl ?? data.mediaInfo?.plexUrl4k}
/>
</div>
</div>
</div>
</div>
{data.credits.cast.length > 0 && (
<>
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<Link href="/movie/[movieId]/cast" as={`/movie/${data.id}/cast`}>
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>{intl.formatMessage(messages.cast)}</span>
<svg
className="w-6 h-6 ml-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</a>
</Link>
</div>
<div className="slider-header">
<Link href="/movie/[movieId]/cast" as={`/movie/${data.id}/cast`}>
<a className="slider-title">
<span>{intl.formatMessage(messages.cast)}</span>
<svg
className="w-6 h-6 ml-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</a>
</Link>
</div>
<Slider
sliderKey="cast"

@ -128,7 +128,7 @@ const PermissionOption: React.FC<PermissionOptionProps> = ({
/>
</div>
<div className="ml-3 text-sm leading-6">
<label htmlFor={option.id} className="block font-medium">
<label htmlFor={option.id} className="block font-medium text-white">
<div className="flex flex-col">
<span>{option.name}</span>
<span className="text-gray-500">{option.description}</span>

@ -15,8 +15,8 @@ import { groupBy } from 'lodash';
import PageTitle from '../Common/PageTitle';
const messages = defineMessages({
appearsin: 'Appears in',
crewmember: 'Crew Member',
appearsin: 'Appearances',
crewmember: 'Crew',
ascharacter: 'as {character}',
nobiography: 'No biography available.',
});
@ -85,11 +85,9 @@ const PersonDetails: React.FC = () => {
const cast = (sortedCast ?? []).length > 0 && (
<>
<div className="relative z-10 mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<div className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>{intl.formatMessage(messages.appearsin)}</span>
</div>
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.appearsin)}</span>
</div>
</div>
<ul className="cardList">
@ -127,11 +125,9 @@ const PersonDetails: React.FC = () => {
const crew = (sortedCrew ?? []).length > 0 && (
<>
<div className="relative z-10 mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<div className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>{intl.formatMessage(messages.crewmember)}</span>
</div>
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.crewmember)}</span>
</div>
</div>
<ul className="cardList">

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import type { MediaRequest } from '../../../server/entity/MediaRequest';
import { FormattedDate, useIntl, defineMessages } from 'react-intl';
import { useIntl, defineMessages } from 'react-intl';
import Badge from '../Common/Badge';
import { MediaRequestStatus } from '../../../server/constants/media';
import Button from '../Common/Button';
@ -228,7 +228,11 @@ const RequestBlock: React.FC<RequestBlockProps> = ({ request, onUpdate }) => {
/>
</svg>
<span>
<FormattedDate value={request.createdAt} />
{intl.formatDate(request.createdAt, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</span>
</div>
</div>

@ -256,7 +256,11 @@ const RequestItem: React.FC<RequestItemProps> = ({
{intl.formatMessage(messages.requested)}
</span>
<span className="text-gray-300">
{intl.formatDate(requestData.createdAt)}
{intl.formatDate(requestData.createdAt, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</span>
</div>
<div className="card-field">

@ -18,12 +18,11 @@ const messages = defineMessages({
qualityprofile: 'Quality Profile',
rootfolder: 'Root Folder',
animenote: '* This series is an anime.',
default: '(Default)',
loadingprofiles: 'Loading profiles…',
loadingfolders: 'Loading folders…',
default: '{name} (Default)',
folder: '{path} ({space})',
requestas: 'Request As',
languageprofile: 'Language Profile',
loadinglanguages: 'Loading languages…',
loading: 'Loading…',
});
export type RequestOverrides = {
@ -266,7 +265,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
<>
<div className="flex flex-col items-center justify-between md:flex-row">
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/4 md:pr-4 md:mb-0">
<label htmlFor="server" className="text-label">
<label htmlFor="server">
{intl.formatMessage(messages.destinationserver)}
</label>
<select
@ -279,16 +278,17 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
>
{data.map((server) => (
<option key={`server-list-${server.id}`} value={server.id}>
{server.name}
{server.isDefault && server.is4k === is4k
? ` ${intl.formatMessage(messages.default)}`
: ''}
? intl.formatMessage(messages.default, {
name: server.name,
})
: server.name}
</option>
))}
</select>
</div>
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/4 md:pr-4 md:mb-0">
<label htmlFor="profile" className="text-label">
<label htmlFor="profile">
{intl.formatMessage(messages.qualityprofile)}
</label>
<select
@ -298,10 +298,11 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
onChange={(e) => setSelectedProfile(Number(e.target.value))}
onBlur={(e) => setSelectedProfile(Number(e.target.value))}
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
disabled={isValidating || !serverData}
>
{isValidating && (
{(isValidating || !serverData) && (
<option value="">
{intl.formatMessage(messages.loadingprofiles)}
{intl.formatMessage(messages.loading)}
</option>
)}
{!isValidating &&
@ -311,14 +312,17 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
key={`profile-list${profile.id}`}
value={profile.id}
>
{profile.name}
{isAnime &&
serverData.server.activeAnimeProfileId === profile.id
? ` ${intl.formatMessage(messages.default)}`
? intl.formatMessage(messages.default, {
name: profile.name,
})
: !isAnime &&
serverData.server.activeProfileId === profile.id
? ` ${intl.formatMessage(messages.default)}`
: ''}
? intl.formatMessage(messages.default, {
name: profile.name,
})
: profile.name}
</option>
))}
</select>
@ -328,7 +332,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
type === 'tv' ? 'md:pr-4' : ''
}`}
>
<label htmlFor="folder" className="text-label">
<label htmlFor="folder">
{intl.formatMessage(messages.rootfolder)}
</label>
<select
@ -338,10 +342,11 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
onChange={(e) => setSelectedFolder(e.target.value)}
onBlur={(e) => setSelectedFolder(e.target.value)}
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
disabled={isValidating || !serverData}
>
{isValidating && (
{(isValidating || !serverData) && (
<option value="">
{intl.formatMessage(messages.loadingfolders)}
{intl.formatMessage(messages.loading)}
</option>
)}
{!isValidating &&
@ -351,21 +356,33 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
key={`folder-list${folder.id}`}
value={folder.path}
>
{folder.path} ({formatBytes(folder.freeSpace ?? 0)})
{isAnime &&
serverData.server.activeAnimeDirectory === folder.path
? ` ${intl.formatMessage(messages.default)}`
? intl.formatMessage(messages.default, {
name: intl.formatMessage(messages.folder, {
path: folder.path,
space: formatBytes(folder.freeSpace ?? 0),
}),
})
: !isAnime &&
serverData.server.activeDirectory === folder.path
? ` ${intl.formatMessage(messages.default)}`
: ''}
? intl.formatMessage(messages.default, {
name: intl.formatMessage(messages.folder, {
path: folder.path,
space: formatBytes(folder.freeSpace ?? 0),
}),
})
: intl.formatMessage(messages.folder, {
path: folder.path,
space: formatBytes(folder.freeSpace ?? 0),
})}
</option>
))}
</select>
</div>
{type === 'tv' && (
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/4 md:mb-0">
<label htmlFor="language" className="text-label">
<label htmlFor="language">
{intl.formatMessage(messages.languageprofile)}
</label>
<select
@ -379,10 +396,11 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
setSelectedLanguage(parseInt(e.target.value))
}
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
disabled={isValidating || !serverData}
>
{isValidating && (
{(isValidating || !serverData) && (
<option value="">
{intl.formatMessage(messages.loadinglanguages)}
{intl.formatMessage(messages.loading)}
</option>
)}
{!isValidating &&
@ -392,16 +410,19 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
key={`folder-list${language.id}`}
value={language.id}
>
{language.name}
{isAnime &&
serverData.server.activeAnimeLanguageProfileId ===
language.id
? ` ${intl.formatMessage(messages.default)}`
? intl.formatMessage(messages.default, {
name: language.name,
})
: !isAnime &&
serverData.server.activeLanguageProfileId ===
language.id
? ` ${intl.formatMessage(messages.default)}`
: ''}
? intl.formatMessage(messages.default, {
name: language.name,
})
: language.name}
</option>
))}
</select>
@ -412,7 +433,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
)}
{hasPermission([Permission.MANAGE_REQUESTS, Permission.MANAGE_USERS]) &&
selectedUser && (
<div className="mt-0 sm:mt-2">
<div className="first:mt-0 sm:mt-4">
<Listbox
as="div"
value={selectedUser}
@ -421,7 +442,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
>
{({ open }) => (
<>
<Listbox.Label className="text-label">
<Listbox.Label>
{intl.formatMessage(messages.requestas)}
</Listbox.Label>
<div className="relative">

@ -279,7 +279,7 @@ const SettingsServices: React.FC = () => {
<p>{intl.formatMessage(messages.nodefaultdescription)}</p>
</Alert>
)}
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<ul className="grid max-w-6xl grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
{radarrData.map((radarr) => (
<ServerInstance
key={`radarr-config-${radarr.id}`}
@ -350,7 +350,7 @@ const SettingsServices: React.FC = () => {
<p>{intl.formatMessage(messages.nodefaultdescription)}</p>
</Alert>
)}
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<ul className="grid max-w-6xl grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-3">
{sonarrData.map((sonarr) => (
<ServerInstance
key={`sonarr-config-${sonarr.id}`}

@ -1,5 +1,5 @@
import React, { useState, useContext, useMemo } from 'react';
import { FormattedDate, defineMessages, useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import { useRouter } from 'next/router';
import Button from '../Common/Button';
@ -60,7 +60,7 @@ const messages = defineMessages({
If this item exists in your Plex library, the media information will be recreated during the next scan.',
approve: 'Approve',
decline: 'Decline',
showtype: 'Show Type',
showtype: 'Series Type',
anime: 'Anime',
network: '{networkCount, plural, one {Network} other {Networks}}',
viewfullcrew: 'View Full Crew',
@ -74,6 +74,8 @@ const messages = defineMessages({
mark4kavailable: 'Mark 4K as Available',
allseasonsmarkedavailable: '* All seasons will be marked as available.',
seasons: '{seasonCount, plural, one {# Season} other {# Seasons}}',
episodeRuntime: 'Episode Runtime',
episodeRuntimeMinutes: '{runtime} minutes',
});
interface TvDetailsProps {
@ -223,7 +225,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
return (
<div
className="px-4 pt-16 -mx-4 -mt-16 bg-center bg-cover"
className="media-page"
style={{
height: 493,
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
@ -415,52 +417,46 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
</div>
)}
</SlideOver>
<div className="flex flex-col items-center pt-4 lg:flex-row lg:items-end">
<div className="lg:mr-4">
<img
src={
data.posterPath
? `//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
: '/images/overseerr_poster_not_found.png'
}
alt=""
className="w-32 rounded shadow md:rounded-lg md:shadow-2xl md:w-44 lg:w-52"
/>
</div>
<div className="flex flex-col flex-1 mt-4 text-center text-white lg:mr-4 lg:mt-0 lg:text-left">
<div className="mb-2 space-x-2">
<span className="ml-2 lg:ml-0">
<StatusBadge
status={data.mediaInfo?.status}
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
plexUrl={data.mediaInfo?.plexUrl}
/>
</span>
<div className="media-header">
<img
src={
data.posterPath
? `//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
: '/images/overseerr_poster_not_found.png'
}
alt=""
className="media-poster"
/>
<div className="media-title">
<div className="media-status">
<StatusBadge
status={data.mediaInfo?.status}
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
plexUrl={data.mediaInfo?.plexUrl}
/>
{settings.currentSettings.series4kEnabled &&
hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], {
type: 'or',
}) && (
<span>
<StatusBadge
status={data.mediaInfo?.status4k}
is4k
inProgress={
(data.mediaInfo?.downloadStatus4k ?? []).length > 0
}
plexUrl4k={data.mediaInfo?.plexUrl4k}
/>
</span>
<StatusBadge
status={data.mediaInfo?.status4k}
is4k
inProgress={
(data.mediaInfo?.downloadStatus4k ?? []).length > 0
}
plexUrl4k={data.mediaInfo?.plexUrl4k}
/>
)}
</div>
<h1 className="text-2xl lg:text-4xl">
<h1>
{data.name}{' '}
{data.firstAirDate && (
<span className="text-2xl">
<span className="media-year">
({data.firstAirDate.slice(0, 4)})
</span>
)}
</h1>
<span className="mt-1 text-xs lg:text-base lg:mt-0">
<span className="media-attributes">
{seriesAttributes.length > 0 &&
seriesAttributes
.map((t, k) => <span key={k}>{t}</span>)
@ -471,29 +467,24 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
))}
</span>
</div>
<div className="relative z-10 flex flex-wrap justify-center flex-shrink-0 mt-4 sm:justify-end sm:flex-nowrap lg:mt-0">
<div className="mb-3 sm:mb-0">
<PlayButton links={mediaLinks} />
</div>
<div className="mb-3 sm:mb-0">
<RequestButton
mediaType="tv"
onUpdate={() => revalidate()}
tmdbId={data?.id}
media={data?.mediaInfo}
isShowComplete={isComplete}
is4kShowComplete={is4kComplete}
/>
</div>
<div className="media-actions">
<PlayButton links={mediaLinks} />
<RequestButton
mediaType="tv"
onUpdate={() => revalidate()}
tmdbId={data?.id}
media={data?.mediaInfo}
isShowComplete={isComplete}
is4kShowComplete={is4kComplete}
/>
{hasPermission(Permission.MANAGE_REQUESTS) && (
<Button
buttonType="default"
className="mb-3 ml-2 first:ml-0 sm:mb-0"
className="ml-2 first:ml-0"
onClick={() => setShowManager(true)}
>
<svg
className="w-5"
style={{ height: 20 }}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -516,17 +507,16 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
)}
</div>
</div>
<div className="flex flex-col pt-8 pb-4 text-white md:flex-row">
<div className="flex-1 md:mr-8">
<h2 className="text-xl md:text-2xl">
{intl.formatMessage(messages.overview)}
</h2>
<p className="pt-2 text-sm md:text-base">
<div className="media-overview">
<div className="media-overview-left">
<div className="tagline">{data.tagline}</div>
<h2>{intl.formatMessage(messages.overview)}</h2>
<p>
{data.overview
? data.overview
: intl.formatMessage(messages.overviewunavailable)}
</p>
<ul className="grid grid-cols-2 gap-6 mt-6 sm:grid-cols-3">
<ul className="media-crew">
{(data.createdBy.length > 0
? [
...data.createdBy.map(
@ -542,15 +532,10 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
)
.slice(0, 6)
.map((person) => (
<li
className="flex flex-col col-span-1"
key={`crew-${person.job}-${person.id}`}
>
<span className="font-bold">{person.job}</span>
<li key={`crew-${person.job}-${person.id}`}>
<span>{person.job}</span>
<Link href={`/person/${person.id}`}>
<a className="text-gray-400 transition duration-300 hover:text-underline hover:text-gray-100">
{person.name}
</a>
<a className="crew-name">{person.name}</a>
</Link>
</li>
))}
@ -579,108 +564,92 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
</div>
)}
</div>
<div className="w-full mt-8 md:w-80 md:mt-0">
<div className="bg-gray-900 border border-gray-800 rounded-lg shadow">
<div className="media-overview-right">
<div className="media-facts">
{(!!data.voteCount ||
(ratingData?.criticsRating && !!ratingData?.criticsScore) ||
(ratingData?.audienceRating && !!ratingData?.audienceScore)) && (
<div className="flex items-center justify-center px-4 py-2 border-b border-gray-800 last:border-b-0">
<div className="media-ratings">
{ratingData?.criticsRating && !!ratingData?.criticsScore && (
<>
<span className="text-sm">
{ratingData.criticsRating === 'Rotten' ? (
<RTRotten className="w-6 mr-1" />
) : (
<RTFresh className="w-6 mr-1" />
)}
</span>
<span className="mr-4 text-sm text-gray-400 last:mr-0">
{ratingData.criticsScore}%
</span>
</>
<span className="media-rating">
{ratingData.criticsRating === 'Rotten' ? (
<RTRotten className="w-6 mr-1" />
) : (
<RTFresh className="w-6 mr-1" />
)}
{ratingData.criticsScore}%
</span>
)}
{ratingData?.audienceRating && !!ratingData?.audienceScore && (
<>
<span className="text-sm">
{ratingData.audienceRating === 'Spilled' ? (
<RTAudRotten className="w-6 mr-1" />
) : (
<RTAudFresh className="w-6 mr-1" />
)}
</span>
<span className="mr-4 text-sm text-gray-400 last:mr-0">
{ratingData.audienceScore}%
</span>
</>
<span className="media-rating">
{ratingData.audienceRating === 'Spilled' ? (
<RTAudRotten className="w-6 mr-1" />
) : (
<RTAudFresh className="w-6 mr-1" />
)}
{ratingData.audienceScore}%
</span>
)}
{!!data.voteCount && (
<>
<span className="text-sm">
<TmdbLogo className="w-6 mr-2" />
</span>
<span className="text-sm text-gray-400">
{data.voteAverage}/10
</span>
</>
<span className="media-rating">
<TmdbLogo className="w-6 mr-2" />
{data.voteAverage}/10
</span>
)}
</div>
)}
{data.keywords.some(
(keyword) => keyword.id === ANIME_KEYWORD_ID
) && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
{intl.formatMessage(messages.showtype)}
</span>
<span className="flex-1 text-sm text-right text-gray-400">
<div className="media-fact">
<span>{intl.formatMessage(messages.showtype)}</span>
<span className="media-fact-value">
{intl.formatMessage(messages.anime)}
</span>
</div>
)}
<div className="media-fact">
<span>{intl.formatMessage(messages.status)}</span>
<span className="media-fact-value">{data.status}</span>
</div>
{data.firstAirDate && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
{intl.formatMessage(messages.firstAirDate)}
</span>
<span className="flex-1 text-sm text-right text-gray-400">
<FormattedDate
value={new Date(data.firstAirDate)}
year="numeric"
month="long"
day="numeric"
/>
<div className="media-fact">
<span>{intl.formatMessage(messages.firstAirDate)}</span>
<span className="media-fact-value">
{intl.formatDate(data.firstAirDate, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</span>
</div>
)}
{data.nextEpisodeToAir && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
{intl.formatMessage(messages.nextAirDate)}
<div className="media-fact">
<span>{intl.formatMessage(messages.nextAirDate)}</span>
<span className="media-fact-value">
{intl.formatDate(data.nextEpisodeToAir.airDate, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</span>
<span className="flex-1 text-sm text-right text-gray-400">
<FormattedDate
value={new Date(data.nextEpisodeToAir?.airDate)}
year="numeric"
month="long"
day="numeric"
/>
</div>
)}
{data.episodeRunTime.length > 0 && (
<div className="media-fact">
<span>{intl.formatMessage(messages.episodeRuntime)}</span>
<span className="media-fact-value">
{intl.formatMessage(messages.episodeRuntimeMinutes, {
runtime: data.episodeRunTime[0],
})}
</span>
</div>
)}
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
{intl.formatMessage(messages.status)}
</span>
<span className="flex-1 text-sm text-right text-gray-400">
{data.status}
</span>
</div>
{data.originalLanguage && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
{intl.formatMessage(messages.originallanguage)}
</span>
<span className="flex-1 text-sm text-right text-gray-400">
<div className="media-fact">
<span>{intl.formatMessage(messages.originallanguage)}</span>
<span className="media-fact-value">
<Link href={`/discover/tv/language/${data.originalLanguage}`}>
<a className="hover:underline">
{intl.formatDisplayName(data.originalLanguage, {
@ -696,13 +665,13 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
</div>
)}
{data.networks.length > 0 && (
<div className="flex px-4 py-2 border-b border-gray-800 last:border-b-0">
<span className="text-sm">
<div className="media-fact">
<span>
{intl.formatMessage(messages.network, {
networkCount: data.networks.length,
})}
</span>
<span className="flex-1 text-sm text-right text-gray-400">
<span className="media-fact-value">
{data.networks
.map((n) => (
<Link
@ -720,43 +689,41 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
</span>
</div>
)}
</div>
<div className="mt-4">
<ExternalLinkBlock
mediaType="tv"
tmdbId={data.id}
tvdbId={data.externalIds.tvdbId}
imdbId={data.externalIds.imdbId}
rtUrl={ratingData?.url}
plexUrl={data.mediaInfo?.plexUrl ?? data.mediaInfo?.plexUrl4k}
/>
<div className="media-fact">
<ExternalLinkBlock
mediaType="tv"
tmdbId={data.id}
tvdbId={data.externalIds.tvdbId}
imdbId={data.externalIds.imdbId}
rtUrl={ratingData?.url}
plexUrl={data.mediaInfo?.plexUrl ?? data.mediaInfo?.plexUrl4k}
/>
</div>
</div>
</div>
</div>
{data.credits.cast.length > 0 && (
<>
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<Link href="/tv/[tvId]/cast" as={`/tv/${data.id}/cast`}>
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>{intl.formatMessage(messages.cast)}</span>
<svg
className="w-6 h-6 ml-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</a>
</Link>
</div>
<div className="slider-header">
<Link href="/tv/[tvId]/cast" as={`/tv/${data.id}/cast`}>
<a className="slider-title">
<span>{intl.formatMessage(messages.cast)}</span>
<svg
className="w-6 h-6 ml-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</a>
</Link>
</div>
<Slider
sliderKey="cast"

@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import PermissionEdit from '../PermissionEdit';
import Modal from '../Common/Modal';
import { User, useUser } from '../../hooks/useUser';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import axios from 'axios';
import { useToasts } from 'react-toast-notifications';
@ -19,8 +19,7 @@ const messages = defineMessages({
save: 'Save Changes',
saving: 'Saving…',
userfail: 'Something went wrong while saving the user.',
permissions: 'Permissions',
edituser: 'Edit User',
edituser: 'Edit User Permissions',
});
const BulkEditModal: React.FC<BulkEditProps> = ({
@ -93,27 +92,12 @@ const BulkEditModal: React.FC<BulkEditProps> = ({
okText={intl.formatMessage(messages.save)}
onCancel={onCancel}
>
<div className="mt-6 mb-6">
<div role="group" aria-labelledby="group-label">
<div className="form-row">
<div>
<div id="group-label" className="group-label">
<FormattedMessage {...messages.permissions} />
</div>
</div>
<div className="form-input">
<div className="max-w-lg">
<PermissionEdit
actingUser={currentUser}
currentPermission={currentPermission}
onUpdate={(newPermission) =>
setCurrentPermission(newPermission)
}
/>
</div>
</div>
</div>
</div>
<div className="mb-6">
<PermissionEdit
actingUser={currentUser}
currentPermission={currentPermission}
onUpdate={(newPermission) => setCurrentPermission(newPermission)}
/>
</div>
</Modal>
);

@ -2,7 +2,7 @@ import React, { useState } from 'react';
import useSWR from 'swr';
import LoadingSpinner from '../Common/LoadingSpinner';
import Badge from '../Common/Badge';
import { FormattedDate, defineMessages, useIntl } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import Button from '../Common/Button';
import { hasPermission } from '../../../server/lib/permissions';
import { Permission, User, UserType, useUser } from '../../hooks/useUser';
@ -551,10 +551,18 @@ const UserList: React.FC = () => {
: intl.formatMessage(messages.user)}
</Table.TD>
<Table.TD>
<FormattedDate value={user.createdAt} />
{intl.formatDate(user.createdAt, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</Table.TD>
<Table.TD>
<FormattedDate value={user.updatedAt} />
{intl.formatDate(user.updatedAt, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</Table.TD>
<Table.TD alignText="right">
<Button

@ -27,7 +27,11 @@ const ProfileHeader: React.FC<ProfileHeaderProps> = ({
const subtextItems: React.ReactNode[] = [
intl.formatMessage(messages.joindate, {
joindate: intl.formatDate(user.createdAt),
joindate: intl.formatDate(user.createdAt, {
year: 'numeric',
month: 'long',
day: 'numeric',
}),
}),
intl.formatMessage(messages.requests, {
requestCount: user.requestCount,
@ -39,7 +43,7 @@ const ProfileHeader: React.FC<ProfileHeaderProps> = ({
}
return (
<div className="relative z-40 mt-6 mb-12 md:flex md:items-end md:justify-between md:space-x-5">
<div className="relative z-40 mt-6 mb-12 lg:flex lg:items-end lg:justify-between lg:space-x-5">
<div className="flex items-end space-x-5 justify-items-end">
<div className="flex-shrink-0">
<div className="relative">
@ -80,7 +84,7 @@ const ProfileHeader: React.FC<ProfileHeaderProps> = ({
</p>
</div>
</div>
<div className="flex flex-col-reverse mt-6 space-y-4 space-y-reverse justify-stretch sm:flex-row-reverse sm:justify-end sm:space-x-reverse sm:space-y-0 sm:space-x-3 md:mt-0 md:flex-row md:space-x-3">
<div className="flex flex-col-reverse mt-6 space-y-4 space-y-reverse justify-stretch lg:flex-row lg:justify-end lg:space-x-reverse lg:space-y-0 lg:space-x-3">
{(loggedInUser?.id === user.id ||
(user.id !== 1 && hasPermission(Permission.MANAGE_USERS))) &&
!isSettingsPage ? (

@ -92,28 +92,15 @@ const UserPermissions: React.FC = () => {
{({ isSubmitting, setFieldValue, values }) => {
return (
<Form className="section">
<div
role="group"
aria-labelledby="group-label"
className="form-group"
>
<div className="form-row">
<span id="group-label" className="group-label">
{intl.formatMessage(messages.permissions)}
</span>
<div className="form-input">
<div className="max-w-lg">
<PermissionEdit
actingUser={currentUser}
currentUser={user}
currentPermission={values.currentPermissions ?? 0}
onUpdate={(newPermission) =>
setFieldValue('currentPermissions', newPermission)
}
/>
</div>
</div>
</div>
<div className="max-w-3xl">
<PermissionEdit
actingUser={currentUser}
currentUser={user}
currentPermission={values.currentPermissions ?? 0}
onUpdate={(newPermission) =>
setFieldValue('currentPermissions', newPermission)
}
/>
</div>
<div className="actions">
<div className="flex justify-end">

@ -2,7 +2,7 @@
"components.AppDataWarning.dockerVolumeMissing": "Docker Volume Mount Missing",
"components.AppDataWarning.dockerVolumeMissingDescription": "The <code>{appDataPath}</code> volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.",
"components.CollectionDetails.movies": "Movies",
"components.CollectionDetails.numberofmovies": "Number of Movies: {count}",
"components.CollectionDetails.numberofmovies": "{count} Movies",
"components.CollectionDetails.overview": "Overview",
"components.CollectionDetails.overviewunavailable": "Overview unavailable.",
"components.CollectionDetails.request": "Request",
@ -141,9 +141,9 @@
"components.PermissionEdit.viewrequestsDescription": "Grants permission to view other users' requests.",
"components.PermissionEdit.vote": "Vote",
"components.PermissionEdit.voteDescription": "Grants permission to vote on requests (voting not yet implemented).",
"components.PersonDetails.appearsin": "Appears in",
"components.PersonDetails.appearsin": "Appearances",
"components.PersonDetails.ascharacter": "as {character}",
"components.PersonDetails.crewmember": "Crew Member",
"components.PersonDetails.crewmember": "Crew",
"components.PersonDetails.nobiography": "No biography available.",
"components.PlexLoginButton.loading": "Loading…",
"components.PlexLoginButton.signingin": "Signing in…",
@ -197,12 +197,11 @@
"components.RequestList.sortModified": "Last Modified",
"components.RequestModal.AdvancedRequester.advancedoptions": "Advanced Options",
"components.RequestModal.AdvancedRequester.animenote": "* This series is an anime.",
"components.RequestModal.AdvancedRequester.default": "(Default)",
"components.RequestModal.AdvancedRequester.default": "{name} (Default)",
"components.RequestModal.AdvancedRequester.destinationserver": "Destination Server",
"components.RequestModal.AdvancedRequester.folder": "{path} ({space})",
"components.RequestModal.AdvancedRequester.languageprofile": "Language Profile",
"components.RequestModal.AdvancedRequester.loadingfolders": "Loading folders…",
"components.RequestModal.AdvancedRequester.loadinglanguages": "Loading languages…",
"components.RequestModal.AdvancedRequester.loadingprofiles": "Loading profiles…",
"components.RequestModal.AdvancedRequester.loading": "Loading…",
"components.RequestModal.AdvancedRequester.qualityprofile": "Quality Profile",
"components.RequestModal.AdvancedRequester.requestas": "Request As",
"components.RequestModal.AdvancedRequester.rootfolder": "Root Folder",
@ -633,6 +632,8 @@
"components.TvDetails.cast": "Cast",
"components.TvDetails.decline": "Decline",
"components.TvDetails.downloadstatus": "Download Status",
"components.TvDetails.episodeRuntime": "Episode Runtime",
"components.TvDetails.episodeRuntimeMinutes": "{runtime} minutes",
"components.TvDetails.firstAirDate": "First Air Date",
"components.TvDetails.manageModalClearMedia": "Clear All Media Data",
"components.TvDetails.manageModalClearMediaWarning": "* This will irreversibly remove all data for this TV series, including any requests. If this item exists in your Plex library, the media information will be recreated during the next scan.",
@ -654,7 +655,7 @@
"components.TvDetails.recommendations": "Recommendations",
"components.TvDetails.recommendationssubtext": "If you liked {title}, you might also like…",
"components.TvDetails.seasons": "{seasonCount, plural, one {# Season} other {# Seasons}}",
"components.TvDetails.showtype": "Show Type",
"components.TvDetails.showtype": "Series Type",
"components.TvDetails.similar": "Similar Series",
"components.TvDetails.similarsubtext": "Other series similar to {title}",
"components.TvDetails.status": "Status",
@ -675,7 +676,7 @@
"components.UserList.deleteconfirm": "Are you sure you want to delete this user? All existing request data from this user will be removed.",
"components.UserList.deleteuser": "Delete User",
"components.UserList.edit": "Edit",
"components.UserList.edituser": "Edit User",
"components.UserList.edituser": "Edit User Permissions",
"components.UserList.email": "Email Address",
"components.UserList.importedfromplex": "{userCount, plural, =0 {No new users} one {# new user} other {# new users}} imported from Plex",
"components.UserList.importfromplex": "Import Users from Plex",
@ -687,7 +688,6 @@
"components.UserList.password": "Password",
"components.UserList.passwordinfo": "Password Information",
"components.UserList.passwordinfodescription": "Email notifications need to be configured and enabled in order to automatically generate passwords.",
"components.UserList.permissions": "Permissions",
"components.UserList.plexuser": "Plex User",
"components.UserList.previous": "Previous",
"components.UserList.resultsperpage": "Display {pageSize} results per page",

@ -1,6 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind screens;
body {
@apply bg-gray-900;
@ -30,7 +31,7 @@ ul.cardList > li {
}
.slider-header {
@apply flex mt-6 mb-4;
@apply relative flex mt-6 mb-4;
}
.slider-title {
@ -41,17 +42,100 @@ a.slider-title {
@apply hover:text-white;
}
.hide-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
.media-page {
@apply px-4 pt-16 -mx-4 -mt-16 bg-center bg-cover;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
.media-header {
@apply flex flex-col items-center pt-4 xl:flex-row xl:items-end;
}
.toast {
width: 360px;
.media-poster {
@apply w-32 rounded shadow md:rounded-lg md:shadow-2xl md:w-44 xl:w-52 xl:mr-4;
}
.media-status {
@apply mb-2 space-x-2;
}
.media-title {
@apply flex flex-col flex-1 mt-4 text-center text-white xl:mr-4 xl:mt-0 xl:text-left;
}
.media-title > h1 {
@apply text-2xl xl:text-4xl;
}
h1 > .media-year {
@apply text-2xl;
}
.media-attributes {
@apply mt-1 text-xs sm:text-sm xl:text-base xl:mt-0;
}
.media-actions {
@apply relative z-10 flex flex-wrap items-center justify-center flex-shrink-0 mt-4 sm:justify-end sm:flex-nowrap xl:mt-0;
}
.media-actions > * {
@apply mb-3 sm:mb-0;
}
.media-overview {
@apply flex flex-col pt-8 pb-4 text-white lg:flex-row;
}
.media-overview-left {
@apply flex-1 lg:mr-8;
}
.tagline {
@apply mb-4 text-xl italic text-gray-400 lg:text-2xl;
}
.media-overview h2 {
@apply text-xl sm:text-2xl;
}
.media-overview p {
@apply pt-2 text-sm text-gray-400 lg:text-base;
}
ul.media-crew {
@apply grid grid-cols-2 gap-6 mt-6 sm:grid-cols-3;
}
ul.media-crew > li {
@apply flex flex-col col-span-1 font-bold;
}
a.crew-name {
@apply font-normal text-gray-400 transition duration-300 hover:underline hover:text-gray-100;
}
.media-overview-right {
@apply w-full mt-8 lg:w-80 lg:mt-0;
}
.media-facts {
@apply text-sm bg-gray-900 border border-gray-700 rounded-lg shadow;
}
.media-fact {
@apply flex px-4 py-2 border-b border-gray-700 last:border-b-0;
}
.media-fact-value {
@apply flex-1 text-sm text-right text-gray-400;
}
.media-ratings {
@apply flex items-center justify-center px-4 py-2 border-b border-gray-700 last:border-b-0;
}
.media-rating {
@apply flex items-center mr-4 last:mr-0;
}
.error-message {
@ -110,12 +194,20 @@ textarea {
@apply pt-5 mt-8 text-white border-t border-gray-700;
}
input[type='checkbox'] {
@apply w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md;
label {
@apply block mb-1 text-sm font-medium leading-5 text-gray-400;
}
label.checkbox-label {
@apply sm:mt-1;
}
label.text-label {
@apply sm:mt-2;
}
.checkbox-label {
@apply block mb-1 text-sm font-medium leading-5 text-gray-400 sm:mt-1;
input[type='checkbox'] {
@apply w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md;
}
input[type='text'],
@ -142,11 +234,6 @@ select.short {
.protocol {
@apply inline-flex items-center px-3 text-gray-100 bg-gray-600 border border-r-0 border-gray-500 cursor-default rounded-l-md sm:text-sm;
}
.text-label {
@apply block mb-1 text-sm font-medium leading-5 text-gray-400 sm:mt-2;
}
.error {
@apply mt-2 text-sm text-red-500;
}
@ -159,11 +246,24 @@ select.short {
@apply block mb-1 text-sm font-medium leading-6 text-gray-400;
}
.toast {
width: 360px;
}
/* Used for animating height */
.extra-max-height {
max-height: 100rem;
}
.hide-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for Chrome, Safari and Opera */
.hide-scrollbar::-webkit-scrollbar {
display: none;

@ -62,7 +62,7 @@ module.exports = {
padding: ['first', 'last', 'responsive'],
borderWidth: ['first', 'last'],
margin: ['first', 'last', 'responsive'],
boxShadow: ['group-focus'],
boxShadow: ['group-focus', 'responsive'],
opacity: ['disabled', 'hover', 'group-hover'],
ringColor: ['focus', 'focus-within', 'hover', 'active'],
scale: ['hover', 'focus', 'group-hover'],

Loading…
Cancel
Save