mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
feat: tv request modal status hookup
This commit is contained in:
@@ -45,6 +45,7 @@ class Media {
|
||||
try {
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { tmdbId: id },
|
||||
relations: ['requests'],
|
||||
});
|
||||
|
||||
return media;
|
||||
@@ -75,7 +76,7 @@ class Media {
|
||||
@Column({ type: 'int', default: MediaStatus.UNKNOWN })
|
||||
public status: MediaStatus;
|
||||
|
||||
@OneToMany(() => MediaRequest, (request) => request.media)
|
||||
@OneToMany(() => MediaRequest, (request) => request.media, { cascade: true })
|
||||
public requests: MediaRequest[];
|
||||
|
||||
@CreateDateColumn()
|
||||
|
@@ -27,7 +27,9 @@ export class MediaRequest {
|
||||
@Column({ type: 'integer' })
|
||||
public status: MediaRequestStatus;
|
||||
|
||||
@ManyToOne(() => Media, (media) => media.requests, { eager: true })
|
||||
@ManyToOne(() => Media, (media) => media.requests, {
|
||||
eager: true,
|
||||
})
|
||||
public media: Media;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.requests, { eager: true })
|
||||
|
@@ -108,7 +108,9 @@ requestRoutes.post(
|
||||
|
||||
const request = new MediaRequest({
|
||||
type: MediaType.TV,
|
||||
media,
|
||||
media: {
|
||||
id: media.id,
|
||||
} as Media,
|
||||
requestedBy: req.user,
|
||||
// If the user is an admin or has the "auto approve" permission, automatically approve the request
|
||||
status: req.user?.hasPermission(Permission.AUTO_APPROVE)
|
||||
|
29
src/components/Common/Badge/index.tsx
Normal file
29
src/components/Common/Badge/index.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
interface BadgeProps {
|
||||
badgeType?: 'default' | 'primary' | 'danger' | 'warning' | 'success';
|
||||
}
|
||||
|
||||
const Badge: React.FC<BadgeProps> = ({ badgeType = 'default', children }) => {
|
||||
const badgeStyle = [
|
||||
'px-2 inline-flex text-xs leading-5 font-semibold rounded-full',
|
||||
];
|
||||
|
||||
switch (badgeType) {
|
||||
case 'danger':
|
||||
badgeStyle.push('bg-red-600 text-red-100');
|
||||
break;
|
||||
case 'warning':
|
||||
badgeStyle.push('bg-orange-400 text-orange-100');
|
||||
break;
|
||||
case 'success':
|
||||
badgeStyle.push('bg-green-500 text-green-100');
|
||||
break;
|
||||
default:
|
||||
badgeStyle.push('bg-indigo-500 text-indigo-100');
|
||||
}
|
||||
|
||||
return <span className={badgeStyle.join(' ')}>{children}</span>;
|
||||
};
|
||||
|
||||
export default Badge;
|
@@ -20,6 +20,7 @@ import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import { useUser, Permission } from '../../hooks/useUser';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import RequestModal from '../RequestModal';
|
||||
import Badge from '../Common/Badge';
|
||||
|
||||
const messages = defineMessages({
|
||||
releasedate: 'Release Date',
|
||||
@@ -98,7 +99,6 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
tmdbId={data.id}
|
||||
show={showRequestModal}
|
||||
type="movie"
|
||||
requestId={data.mediaInfo?.requests?.[0]?.id}
|
||||
onComplete={() => {
|
||||
revalidate();
|
||||
setShowRequestModal(false);
|
||||
@@ -114,10 +114,21 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
/>
|
||||
</div>
|
||||
<div className="text-white flex flex-col mr-4 mt-4 md:mt-0 text-center md:text-left">
|
||||
<span className="md:text-2xl md:leading-none">
|
||||
{data.releaseDate.slice(0, 4)}
|
||||
</span>
|
||||
<h1 className="text-2xl md:text-4xl">{data.title}</h1>
|
||||
<div className="mb-2 md:mb-0">
|
||||
{data.mediaInfo?.status === MediaStatus.AVAILABLE && (
|
||||
<Badge badgeType="success">Available</Badge>
|
||||
)}
|
||||
{data.mediaInfo?.status === MediaStatus.PROCESSING && (
|
||||
<Badge badgeType="danger">Unavailable</Badge>
|
||||
)}
|
||||
{data.mediaInfo?.status === MediaStatus.PENDING && (
|
||||
<Badge badgeType="warning">Pending</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-4xl">
|
||||
{data.title}{' '}
|
||||
<span className="text-2xl">({data.releaseDate.slice(0, 4)})</span>
|
||||
</h1>
|
||||
<span className="text-xs md:text-base mt-1 md:mt-0">
|
||||
{(data.runtime ?? 0) > 0 && (
|
||||
<>
|
||||
|
@@ -7,8 +7,13 @@ import { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||
import useSWR from 'swr';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import axios from 'axios';
|
||||
import type { MediaStatus } from '../../../server/constants/media';
|
||||
import {
|
||||
MediaStatus,
|
||||
MediaRequestStatus,
|
||||
} from '../../../server/constants/media';
|
||||
import { TvDetails, SeasonWithEpisodes } from '../../../server/models/Tv';
|
||||
import type SeasonRequest from '../../../server/entity/SeasonRequest';
|
||||
import Badge from '../Common/Badge';
|
||||
|
||||
const messages = defineMessages({
|
||||
requestadmin: 'Your request will be immediately approved.',
|
||||
@@ -71,11 +76,24 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const getAllRequestedSeasons = (): number[] =>
|
||||
(data?.mediaInfo?.requests ?? []).reduce((requestedSeasons, request) => {
|
||||
return [
|
||||
...requestedSeasons,
|
||||
...request.seasons.map((sr) => sr.seasonNumber),
|
||||
];
|
||||
}, [] as number[]);
|
||||
|
||||
const isSelectedSeason = (seasonNumber: number): boolean => {
|
||||
return selectedSeasons.includes(seasonNumber);
|
||||
};
|
||||
|
||||
const toggleSeason = (seasonNumber: number): void => {
|
||||
// If this season already has a pending request, don't allow it to be toggled
|
||||
if (getAllRequestedSeasons().includes(seasonNumber)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedSeasons.includes(seasonNumber)) {
|
||||
setSelectedSeasons((seasons) =>
|
||||
seasons.filter((sn) => sn !== seasonNumber)
|
||||
@@ -90,11 +108,18 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
data &&
|
||||
selectedSeasons.length >= 0 &&
|
||||
selectedSeasons.length <
|
||||
data?.seasons.filter((season) => season.seasonNumber !== 0).length
|
||||
data?.seasons
|
||||
.filter((season) => season.seasonNumber !== 0)
|
||||
.filter(
|
||||
(season) => !getAllRequestedSeasons().includes(season.seasonNumber)
|
||||
).length
|
||||
) {
|
||||
setSelectedSeasons(
|
||||
data.seasons
|
||||
.filter((season) => season.seasonNumber !== 0)
|
||||
.filter(
|
||||
(season) => !getAllRequestedSeasons().includes(season.seasonNumber)
|
||||
)
|
||||
.map((season) => season.seasonNumber)
|
||||
);
|
||||
} else {
|
||||
@@ -108,7 +133,11 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
}
|
||||
return (
|
||||
selectedSeasons.length ===
|
||||
data.seasons.filter((season) => season.seasonNumber !== 0).length
|
||||
data.seasons
|
||||
.filter((season) => season.seasonNumber !== 0)
|
||||
.filter(
|
||||
(season) => !getAllRequestedSeasons().includes(season.seasonNumber)
|
||||
).length
|
||||
);
|
||||
};
|
||||
|
||||
@@ -116,6 +145,23 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
? intl.formatMessage(messages.requestadmin)
|
||||
: undefined;
|
||||
|
||||
const getSeasonRequest = (
|
||||
seasonNumber: number
|
||||
): SeasonRequest | undefined => {
|
||||
let seasonRequest: SeasonRequest | undefined;
|
||||
if (data?.mediaInfo && (data.mediaInfo.requests || []).length > 0) {
|
||||
data.mediaInfo.requests.forEach((request) => {
|
||||
if (!seasonRequest) {
|
||||
seasonRequest = request.seasons.find(
|
||||
(season) => season.seasonNumber === seasonNumber
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return seasonRequest;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
@@ -196,54 +242,76 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
|
||||
<tbody className="bg-cool-gray-600 divide-y">
|
||||
{data?.seasons
|
||||
.filter((season) => season.seasonNumber !== 0)
|
||||
.map((season) => (
|
||||
<tr key={`season-${season.id}`}>
|
||||
<td className="px-4 py-4 whitespace-no-wrap text-sm leading-5 font-medium text-gray-100">
|
||||
<span
|
||||
role="checkbox"
|
||||
tabIndex={0}
|
||||
aria-checked={isSelectedSeason(season.seasonNumber)}
|
||||
onClick={() => toggleSeason(season.seasonNumber)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === 'Space') {
|
||||
toggleSeason(season.seasonNumber);
|
||||
.map((season) => {
|
||||
const seasonRequest = getSeasonRequest(
|
||||
season.seasonNumber
|
||||
);
|
||||
return (
|
||||
<tr key={`season-${season.id}`}>
|
||||
<td className="px-4 py-4 whitespace-no-wrap text-sm leading-5 font-medium text-gray-100">
|
||||
<span
|
||||
role="checkbox"
|
||||
tabIndex={0}
|
||||
aria-checked={
|
||||
!!seasonRequest ||
|
||||
isSelectedSeason(season.seasonNumber)
|
||||
}
|
||||
}}
|
||||
className="group relative inline-flex items-center justify-center flex-shrink-0 h-5 w-10 cursor-pointer focus:outline-none"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
isSelectedSeason(season.seasonNumber)
|
||||
? 'bg-indigo-500'
|
||||
: 'bg-gray-800'
|
||||
} absolute h-4 w-9 mx-auto rounded-full transition-colors ease-in-out duration-200`}
|
||||
></span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
isSelectedSeason(season.seasonNumber)
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0'
|
||||
} absolute left-0 inline-block h-5 w-5 border border-gray-200 rounded-full bg-white shadow transform group-focus:shadow-outline group-focus:border-blue-300 transition-transform ease-in-out duration-200`}
|
||||
></span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-no-wrap text-sm leading-5 font-medium text-gray-100">
|
||||
{season.seasonNumber === 0
|
||||
? 'Extras'
|
||||
: `Season ${season.seasonNumber}`}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-200">
|
||||
{season.episodeCount}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-200">
|
||||
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-400 text-green-800">
|
||||
Available
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
onClick={() => toggleSeason(season.seasonNumber)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === 'Space') {
|
||||
toggleSeason(season.seasonNumber);
|
||||
}
|
||||
}}
|
||||
className={`group relative inline-flex items-center justify-center flex-shrink-0 h-5 w-10 cursor-pointer focus:outline-none ${
|
||||
seasonRequest ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
!!seasonRequest ||
|
||||
isSelectedSeason(season.seasonNumber)
|
||||
? 'bg-indigo-500'
|
||||
: 'bg-gray-800'
|
||||
} absolute h-4 w-9 mx-auto rounded-full transition-colors ease-in-out duration-200`}
|
||||
></span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
!!seasonRequest ||
|
||||
isSelectedSeason(season.seasonNumber)
|
||||
? 'translate-x-5'
|
||||
: 'translate-x-0'
|
||||
} absolute left-0 inline-block h-5 w-5 border border-gray-200 rounded-full bg-white shadow transform group-focus:shadow-outline group-focus:border-blue-300 transition-transform ease-in-out duration-200`}
|
||||
></span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-no-wrap text-sm leading-5 font-medium text-gray-100">
|
||||
{season.seasonNumber === 0
|
||||
? 'Extras'
|
||||
: `Season ${season.seasonNumber}`}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-200">
|
||||
{season.episodeCount}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-200">
|
||||
{!seasonRequest && <Badge>Not Requested</Badge>}
|
||||
{seasonRequest?.status ===
|
||||
MediaRequestStatus.PENDING && (
|
||||
<Badge badgeType="warning">Pending</Badge>
|
||||
)}
|
||||
{seasonRequest?.status ===
|
||||
MediaRequestStatus.APPROVED && (
|
||||
<Badge badgeType="danger">Unavailable</Badge>
|
||||
)}
|
||||
{seasonRequest?.status ===
|
||||
MediaRequestStatus.AVAILABLE && (
|
||||
<Badge badgeType="success">Available</Badge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
@@ -6,7 +6,6 @@ import type { MediaStatus } from '../../../server/constants/media';
|
||||
import TvRequestModal from './TvRequestModal';
|
||||
|
||||
interface RequestModalProps {
|
||||
requestId?: number;
|
||||
show: boolean;
|
||||
type: 'movie' | 'tv';
|
||||
tmdbId: number;
|
||||
@@ -18,7 +17,6 @@ interface RequestModalProps {
|
||||
|
||||
const RequestModal: React.FC<RequestModalProps> = ({
|
||||
type,
|
||||
requestId,
|
||||
show,
|
||||
tmdbId,
|
||||
onComplete,
|
||||
@@ -26,16 +24,12 @@ const RequestModal: React.FC<RequestModalProps> = ({
|
||||
onUpdating,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { data } = useSWR<MediaRequest>(
|
||||
requestId ? `/api/v1/request/${requestId}` : null
|
||||
);
|
||||
if (type === 'tv') {
|
||||
return (
|
||||
<TvRequestModal
|
||||
onComplete={onComplete}
|
||||
onCancel={onCancel}
|
||||
visible={show}
|
||||
request={data}
|
||||
tmdbId={tmdbId}
|
||||
onUpdating={onUpdating}
|
||||
/>
|
||||
@@ -47,7 +41,6 @@ const RequestModal: React.FC<RequestModalProps> = ({
|
||||
onComplete={onComplete}
|
||||
onCancel={onCancel}
|
||||
visible={show}
|
||||
request={data}
|
||||
tmdbId={tmdbId}
|
||||
onUpdating={onUpdating}
|
||||
/>
|
||||
|
@@ -14,6 +14,7 @@ import { useUser, Permission } from '../../hooks/useUser';
|
||||
import { TvDetails as TvDetailsType } from '../../../server/models/Tv';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import RequestModal from '../RequestModal';
|
||||
import Badge from '../Common/Badge';
|
||||
|
||||
const messages = defineMessages({
|
||||
userrating: 'User Rating',
|
||||
@@ -88,7 +89,6 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
tmdbId={data.id}
|
||||
show={showRequestModal}
|
||||
type="tv"
|
||||
requestId={data.mediaInfo?.requests?.[0]?.id}
|
||||
onComplete={() => {
|
||||
revalidate();
|
||||
setShowRequestModal(false);
|
||||
@@ -104,17 +104,31 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
/>
|
||||
</div>
|
||||
<div className="text-white flex flex-col mr-4 mt-4 md:mt-0 text-center md:text-left">
|
||||
<span className="md:text-2xl md:leading-none">
|
||||
{data.firstAirDate.slice(0, 4)}
|
||||
</span>
|
||||
<h1 className="text-2xl md:text-4xl">{data.name}</h1>
|
||||
<div className="mb-2 md:mb-0">
|
||||
{data.mediaInfo?.status === MediaStatus.AVAILABLE && (
|
||||
<Badge badgeType="success">Available</Badge>
|
||||
)}
|
||||
{data.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE && (
|
||||
<Badge badgeType="success">Partially Available</Badge>
|
||||
)}
|
||||
{data.mediaInfo?.status === MediaStatus.PROCESSING && (
|
||||
<Badge badgeType="danger">Unavailable</Badge>
|
||||
)}
|
||||
{data.mediaInfo?.status === MediaStatus.PENDING && (
|
||||
<Badge badgeType="warning">Pending</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-4xl">
|
||||
{data.name}{' '}
|
||||
<span className="text-2xl">({data.firstAirDate.slice(0, 4)})</span>
|
||||
</h1>
|
||||
<span className="text-xs md:text-base mt-1 md:mt-0">
|
||||
{data.genres.map((g) => g.name).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 flex justify-end mt-4 md:mt-0">
|
||||
{(!data.mediaInfo ||
|
||||
data.mediaInfo.status === MediaStatus.UNKNOWN) && (
|
||||
data.mediaInfo.status !== MediaStatus.AVAILABLE) && (
|
||||
<Button
|
||||
buttonType="primary"
|
||||
onClick={() => setShowRequestModal(true)}
|
||||
@@ -136,63 +150,6 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
<FormattedMessage {...messages.request} />
|
||||
</Button>
|
||||
)}
|
||||
{data.mediaInfo?.status === MediaStatus.PENDING && (
|
||||
<Button buttonType="warning">
|
||||
<svg
|
||||
className="w-4 mr-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="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedMessage {...messages.pending} />
|
||||
</Button>
|
||||
)}
|
||||
{data.mediaInfo?.status === MediaStatus.PROCESSING && (
|
||||
<Button buttonType="danger">
|
||||
<svg
|
||||
className="w-5 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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedMessage {...messages.unavailable} />
|
||||
</Button>
|
||||
)}
|
||||
{data.mediaInfo?.status === MediaStatus.AVAILABLE && (
|
||||
<Button buttonType="success">
|
||||
<svg
|
||||
className="w-5 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="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedMessage {...messages.available} />
|
||||
</Button>
|
||||
)}
|
||||
<Button buttonType="danger" className="ml-2">
|
||||
<svg
|
||||
className="w-5"
|
||||
|
Reference in New Issue
Block a user