feat: add tagline, episode runtime, genres list to media details & clean/refactor CSS into globals (#1160)
This commit is contained in:
@@ -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)}
|
||||
</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 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>
|
||||
</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)}
|
||||
{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>
|
||||
<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}`}>
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user