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

This commit is contained in:
TheCatLady
2021-03-14 20:16:39 -04:00
committed by GitHub
parent 3d6b343413
commit 2f2e00237d
23 changed files with 683 additions and 584 deletions

View File

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

View File

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

View File

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

View File

@@ -95,7 +95,9 @@ router.get<{ id: string }>('/network/:id', async (req, res) => {
router.get('/genres/movie', isAuthenticated(), async (req, res) => { router.get('/genres/movie', isAuthenticated(), async (req, res) => {
const tmdb = new TheMovieDb(); 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); 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) => { router.get('/genres/tv', isAuthenticated(), async (req, res) => {
const tmdb = new TheMovieDb(); 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); return res.status(200).json(genres);
}); });

View File

@@ -19,12 +19,14 @@ import Transition from '../Transition';
import PageTitle from '../Common/PageTitle'; import PageTitle from '../Common/PageTitle';
import { useUser, Permission } from '../../hooks/useUser'; import { useUser, Permission } from '../../hooks/useUser';
import useSettings from '../../hooks/useSettings'; import useSettings from '../../hooks/useSettings';
import Link from 'next/link';
import { uniq } from 'lodash';
const messages = defineMessages({ const messages = defineMessages({
overviewunavailable: 'Overview unavailable.', overviewunavailable: 'Overview unavailable.',
overview: 'Overview', overview: 'Overview',
movies: 'Movies', movies: 'Movies',
numberofmovies: 'Number of Movies: {count}', numberofmovies: '{count} Movies',
requesting: 'Requesting…', requesting: 'Requesting…',
request: 'Request', request: 'Request',
requestcollection: 'Request Collection', 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) { if (!data && !error) {
return <LoadingSpinner />; return <LoadingSpinner />;
} }
@@ -105,6 +111,17 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
collectionStatus4k = MediaStatus.PARTIALLY_AVAILABLE; 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( const requestableParts = data.parts.filter(
(part) => (part) =>
!part.mediaInfo || !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 ( return (
<div <div
className="px-4 pt-16 -mx-4 -mt-16 bg-center bg-cover" className="media-page"
style={{ style={{
height: 493, 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})`, 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> </ul>
</Modal> </Modal>
</Transition> </Transition>
<div className="flex flex-col items-center pt-4 lg:flex-row lg:items-end"> <div className="media-header">
<div className="lg:mr-4"> <img
<img src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`} alt=""
alt="" className="media-poster"
className="w-32 rounded shadow md:rounded-lg md:shadow-2xl md:w-44 lg:w-52" />
/> <div className="media-title">
</div> <div className="media-status">
<div className="flex flex-col flex-1 mt-4 text-center text-white lg:mr-4 lg:mt-0 lg:text-left"> <StatusBadge
<div className="mb-2 space-x-2"> status={collectionStatus}
<span className="ml-2 lg:ml-0"> inProgress={data.parts.some(
<StatusBadge (part) => (part.mediaInfo?.downloadStatus ?? []).length > 0
status={collectionStatus} )}
inProgress={data.parts.some( />
(part) => (part.mediaInfo?.downloadStatus ?? []).length > 0
)}
/>
</span>
{settings.currentSettings.movie4kEnabled && {settings.currentSettings.movie4kEnabled &&
hasPermission( hasPermission(
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
@@ -241,43 +288,83 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
type: 'or', type: 'or',
} }
) && ( ) && (
<span> <StatusBadge
<StatusBadge status={collectionStatus4k}
status={collectionStatus4k} is4k
is4k inProgress={data.parts.some(
inProgress={data.parts.some( (part) =>
(part) => (part.mediaInfo?.downloadStatus4k ?? []).length > 0
(part.mediaInfo?.downloadStatus4k ?? []).length > 0 )}
)} />
/>
</span>
)} )}
</div> </div>
<h1 className="text-2xl md:text-4xl">{data.name}</h1> <h1>{data.name}</h1>
<span className="mt-1 text-xs lg:text-base lg:mt-0"> <span className="media-attributes">
{intl.formatMessage(messages.numberofmovies, { {collectionAttributes.length > 0 &&
count: data.parts.length, collectionAttributes
})} .map((t, k) => <span key={k}>{t}</span>)
.reduce((prev, curr) => (
<>
{prev} | {curr}
</>
))}
</span> </span>
</div> </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) && {hasPermission(Permission.REQUEST) &&
(collectionStatus !== MediaStatus.AVAILABLE || (hasRequestable ||
(settings.currentSettings.movie4kEnabled && (settings.currentSettings.movie4kEnabled &&
hasPermission( hasPermission(
[Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], [Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE],
{ type: 'or' } { type: 'or' }
) && ) &&
collectionStatus4k !== MediaStatus.AVAILABLE)) && ( hasRequestable4k)) && (
<div className="mb-3 sm:mb-0"> <ButtonWithDropdown
<ButtonWithDropdown buttonType="primary"
buttonType="primary" onClick={() => {
onClick={() => { setRequestModal(true);
setRequestModal(true); setIs4k(!hasRequestable);
setIs4k(collectionStatus === MediaStatus.AVAILABLE); }}
}} text={
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 <svg
className="w-4 mr-1" className="w-4 mr-1"
fill="none" fill="none"
@@ -293,70 +380,27 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
/> />
</svg> </svg>
<span> <span>
{intl.formatMessage( {intl.formatMessage(messages.requestcollection4k)}
collectionStatus === MediaStatus.AVAILABLE
? messages.requestcollection4k
: messages.requestcollection
)}
</span> </span>
</> </ButtonWithDropdown.Item>
} )}
> </ButtonWithDropdown>
{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>
)} )}
</div> </div>
</div> </div>
<div className="flex flex-col pt-8 pb-4 text-white md:flex-row"> <div className="media-overview">
<div className="flex-1 md:mr-8"> <div className="flex-1">
<h2 className="text-xl md:text-2xl"> <h2>{intl.formatMessage(messages.overview)}</h2>
{intl.formatMessage(messages.overview)} <p>
</h2>
<p className="pt-2 text-sm md:text-base">
{data.overview {data.overview
? data.overview ? data.overview
: intl.formatMessage(messages.overviewunavailable)} : intl.formatMessage(messages.overviewunavailable)}
</p> </p>
</div> </div>
</div> </div>
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between"> <div className="slider-header">
<div className="flex-1 min-w-0"> <div className="slider-title">
<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>
<span>{intl.formatMessage(messages.movies)}</span>
</div>
</div> </div>
</div> </div>
<Slider <Slider

View File

@@ -45,37 +45,37 @@ function Button<P extends ElementTypes = 'button'>(
ref?: React.Ref<Element<P>> ref?: React.Ref<Element<P>>
): JSX.Element { ): JSX.Element {
const buttonStyle = [ 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) { switch (buttonType) {
case 'primary': case 'primary':
buttonStyle.push( 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; break;
case 'danger': case 'danger':
buttonStyle.push( 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; break;
case 'warning': case 'warning':
buttonStyle.push( 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; break;
case 'success': case 'success':
buttonStyle.push( 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; break;
case 'ghost': case 'ghost':
buttonStyle.push( 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; break;
default: default:
buttonStyle.push( 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'
); );
} }

View File

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

View File

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

View File

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

View File

@@ -128,7 +128,7 @@ const PermissionOption: React.FC<PermissionOptionProps> = ({
/> />
</div> </div>
<div className="ml-3 text-sm leading-6"> <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"> <div className="flex flex-col">
<span>{option.name}</span> <span>{option.name}</span>
<span className="text-gray-500">{option.description}</span> <span className="text-gray-500">{option.description}</span>

View File

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

View File

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

View File

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

View File

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

View File

@@ -279,7 +279,7 @@ const SettingsServices: React.FC = () => {
<p>{intl.formatMessage(messages.nodefaultdescription)}</p> <p>{intl.formatMessage(messages.nodefaultdescription)}</p>
</Alert> </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) => ( {radarrData.map((radarr) => (
<ServerInstance <ServerInstance
key={`radarr-config-${radarr.id}`} key={`radarr-config-${radarr.id}`}
@@ -350,7 +350,7 @@ const SettingsServices: React.FC = () => {
<p>{intl.formatMessage(messages.nodefaultdescription)}</p> <p>{intl.formatMessage(messages.nodefaultdescription)}</p>
</Alert> </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) => ( {sonarrData.map((sonarr) => (
<ServerInstance <ServerInstance
key={`sonarr-config-${sonarr.id}`} key={`sonarr-config-${sonarr.id}`}

View File

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

View File

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

View File

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

View File

@@ -27,7 +27,11 @@ const ProfileHeader: React.FC<ProfileHeaderProps> = ({
const subtextItems: React.ReactNode[] = [ const subtextItems: React.ReactNode[] = [
intl.formatMessage(messages.joindate, { intl.formatMessage(messages.joindate, {
joindate: intl.formatDate(user.createdAt), joindate: intl.formatDate(user.createdAt, {
year: 'numeric',
month: 'long',
day: 'numeric',
}),
}), }),
intl.formatMessage(messages.requests, { intl.formatMessage(messages.requests, {
requestCount: user.requestCount, requestCount: user.requestCount,
@@ -39,7 +43,7 @@ const ProfileHeader: React.FC<ProfileHeaderProps> = ({
} }
return ( 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 items-end space-x-5 justify-items-end">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<div className="relative"> <div className="relative">
@@ -80,7 +84,7 @@ const ProfileHeader: React.FC<ProfileHeaderProps> = ({
</p> </p>
</div> </div>
</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 || {(loggedInUser?.id === user.id ||
(user.id !== 1 && hasPermission(Permission.MANAGE_USERS))) && (user.id !== 1 && hasPermission(Permission.MANAGE_USERS))) &&
!isSettingsPage ? ( !isSettingsPage ? (

View File

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

View File

@@ -2,7 +2,7 @@
"components.AppDataWarning.dockerVolumeMissing": "Docker Volume Mount Missing", "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.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.movies": "Movies",
"components.CollectionDetails.numberofmovies": "Number of Movies: {count}", "components.CollectionDetails.numberofmovies": "{count} Movies",
"components.CollectionDetails.overview": "Overview", "components.CollectionDetails.overview": "Overview",
"components.CollectionDetails.overviewunavailable": "Overview unavailable.", "components.CollectionDetails.overviewunavailable": "Overview unavailable.",
"components.CollectionDetails.request": "Request", "components.CollectionDetails.request": "Request",
@@ -141,9 +141,9 @@
"components.PermissionEdit.viewrequestsDescription": "Grants permission to view other users' requests.", "components.PermissionEdit.viewrequestsDescription": "Grants permission to view other users' requests.",
"components.PermissionEdit.vote": "Vote", "components.PermissionEdit.vote": "Vote",
"components.PermissionEdit.voteDescription": "Grants permission to vote on requests (voting not yet implemented).", "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.ascharacter": "as {character}",
"components.PersonDetails.crewmember": "Crew Member", "components.PersonDetails.crewmember": "Crew",
"components.PersonDetails.nobiography": "No biography available.", "components.PersonDetails.nobiography": "No biography available.",
"components.PlexLoginButton.loading": "Loading…", "components.PlexLoginButton.loading": "Loading…",
"components.PlexLoginButton.signingin": "Signing in…", "components.PlexLoginButton.signingin": "Signing in…",
@@ -197,12 +197,11 @@
"components.RequestList.sortModified": "Last Modified", "components.RequestList.sortModified": "Last Modified",
"components.RequestModal.AdvancedRequester.advancedoptions": "Advanced Options", "components.RequestModal.AdvancedRequester.advancedoptions": "Advanced Options",
"components.RequestModal.AdvancedRequester.animenote": "* This series is an anime.", "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.destinationserver": "Destination Server",
"components.RequestModal.AdvancedRequester.folder": "{path} ({space})",
"components.RequestModal.AdvancedRequester.languageprofile": "Language Profile", "components.RequestModal.AdvancedRequester.languageprofile": "Language Profile",
"components.RequestModal.AdvancedRequester.loadingfolders": "Loading folders…", "components.RequestModal.AdvancedRequester.loading": "Loading…",
"components.RequestModal.AdvancedRequester.loadinglanguages": "Loading languages…",
"components.RequestModal.AdvancedRequester.loadingprofiles": "Loading profiles…",
"components.RequestModal.AdvancedRequester.qualityprofile": "Quality Profile", "components.RequestModal.AdvancedRequester.qualityprofile": "Quality Profile",
"components.RequestModal.AdvancedRequester.requestas": "Request As", "components.RequestModal.AdvancedRequester.requestas": "Request As",
"components.RequestModal.AdvancedRequester.rootfolder": "Root Folder", "components.RequestModal.AdvancedRequester.rootfolder": "Root Folder",
@@ -633,6 +632,8 @@
"components.TvDetails.cast": "Cast", "components.TvDetails.cast": "Cast",
"components.TvDetails.decline": "Decline", "components.TvDetails.decline": "Decline",
"components.TvDetails.downloadstatus": "Download Status", "components.TvDetails.downloadstatus": "Download Status",
"components.TvDetails.episodeRuntime": "Episode Runtime",
"components.TvDetails.episodeRuntimeMinutes": "{runtime} minutes",
"components.TvDetails.firstAirDate": "First Air Date", "components.TvDetails.firstAirDate": "First Air Date",
"components.TvDetails.manageModalClearMedia": "Clear All Media Data", "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.", "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.recommendations": "Recommendations",
"components.TvDetails.recommendationssubtext": "If you liked {title}, you might also like…", "components.TvDetails.recommendationssubtext": "If you liked {title}, you might also like…",
"components.TvDetails.seasons": "{seasonCount, plural, one {# Season} other {# Seasons}}", "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.similar": "Similar Series",
"components.TvDetails.similarsubtext": "Other series similar to {title}", "components.TvDetails.similarsubtext": "Other series similar to {title}",
"components.TvDetails.status": "Status", "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.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.deleteuser": "Delete User",
"components.UserList.edit": "Edit", "components.UserList.edit": "Edit",
"components.UserList.edituser": "Edit User", "components.UserList.edituser": "Edit User Permissions",
"components.UserList.email": "Email Address", "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.importedfromplex": "{userCount, plural, =0 {No new users} one {# new user} other {# new users}} imported from Plex",
"components.UserList.importfromplex": "Import Users from Plex", "components.UserList.importfromplex": "Import Users from Plex",
@@ -687,7 +688,6 @@
"components.UserList.password": "Password", "components.UserList.password": "Password",
"components.UserList.passwordinfo": "Password Information", "components.UserList.passwordinfo": "Password Information",
"components.UserList.passwordinfodescription": "Email notifications need to be configured and enabled in order to automatically generate passwords.", "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.plexuser": "Plex User",
"components.UserList.previous": "Previous", "components.UserList.previous": "Previous",
"components.UserList.resultsperpage": "Display {pageSize} results per page", "components.UserList.resultsperpage": "Display {pageSize} results per page",

View File

@@ -1,6 +1,7 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@tailwind screens;
body { body {
@apply bg-gray-900; @apply bg-gray-900;
@@ -30,7 +31,7 @@ ul.cardList > li {
} }
.slider-header { .slider-header {
@apply flex mt-6 mb-4; @apply relative flex mt-6 mb-4;
} }
.slider-title { .slider-title {
@@ -41,17 +42,100 @@ a.slider-title {
@apply hover:text-white; @apply hover:text-white;
} }
.hide-scrollbar { .media-page {
-ms-overflow-style: none; /* IE and Edge */ @apply px-4 pt-16 -mx-4 -mt-16 bg-center bg-cover;
scrollbar-width: none; /* Firefox */
} }
.hide-scrollbar::-webkit-scrollbar { .media-header {
display: none; @apply flex flex-col items-center pt-4 xl:flex-row xl:items-end;
} }
.toast { .media-poster {
width: 360px; @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 { .error-message {
@@ -110,12 +194,20 @@ textarea {
@apply pt-5 mt-8 text-white border-t border-gray-700; @apply pt-5 mt-8 text-white border-t border-gray-700;
} }
input[type='checkbox'] { label {
@apply w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md; @apply block mb-1 text-sm font-medium leading-5 text-gray-400;
} }
.checkbox-label { label.checkbox-label {
@apply block mb-1 text-sm font-medium leading-5 text-gray-400 sm:mt-1; @apply sm:mt-1;
}
label.text-label {
@apply sm:mt-2;
}
input[type='checkbox'] {
@apply w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md;
} }
input[type='text'], input[type='text'],
@@ -142,11 +234,6 @@ select.short {
.protocol { .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; @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 { .error {
@apply mt-2 text-sm text-red-500; @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; @apply block mb-1 text-sm font-medium leading-6 text-gray-400;
} }
.toast {
width: 360px;
}
/* Used for animating height */ /* Used for animating height */
.extra-max-height { .extra-max-height {
max-height: 100rem; 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 for Chrome, Safari and Opera */
.hide-scrollbar::-webkit-scrollbar { .hide-scrollbar::-webkit-scrollbar {
display: none; display: none;

View File

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