mirror of
https://github.com/sct/overseerr.git
synced 2025-09-17 17:24:35 +02:00
@@ -463,6 +463,19 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/Crew'
|
$ref: '#/components/schemas/Crew'
|
||||||
|
collection:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: number
|
||||||
|
example: 1
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
example: A collection
|
||||||
|
posterPath:
|
||||||
|
type: string
|
||||||
|
backdropPath:
|
||||||
|
type: string
|
||||||
externalIds:
|
externalIds:
|
||||||
$ref: '#/components/schemas/ExternalIds'
|
$ref: '#/components/schemas/ExternalIds'
|
||||||
mediaInfo:
|
mediaInfo:
|
||||||
@@ -991,6 +1004,26 @@ components:
|
|||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
example: 'English'
|
example: 'English'
|
||||||
|
Collection:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: number
|
||||||
|
example: 123
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
example: A Movie Collection
|
||||||
|
overview:
|
||||||
|
type: string
|
||||||
|
example: Overview of collection
|
||||||
|
posterPath:
|
||||||
|
type: string
|
||||||
|
backdropPath:
|
||||||
|
type: string
|
||||||
|
parts:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/MovieResult'
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
cookieAuth:
|
cookieAuth:
|
||||||
type: apiKey
|
type: apiKey
|
||||||
@@ -2626,6 +2659,31 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'204':
|
'204':
|
||||||
description: Succesfully removed media item
|
description: Succesfully removed media item
|
||||||
|
/collection/{collectionId}:
|
||||||
|
get:
|
||||||
|
summary: Request collection details
|
||||||
|
description: Returns back full collection details in JSON format
|
||||||
|
tags:
|
||||||
|
- collection
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: collectionId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
example: 537982
|
||||||
|
- in: query
|
||||||
|
name: language
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: en
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Collection details
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Collection'
|
||||||
|
|
||||||
security:
|
security:
|
||||||
- cookieAuth: []
|
- cookieAuth: []
|
||||||
|
@@ -190,6 +190,12 @@ export interface TmdbMovieDetails {
|
|||||||
cast: TmdbCreditCast[];
|
cast: TmdbCreditCast[];
|
||||||
crew: TmdbCreditCrew[];
|
crew: TmdbCreditCrew[];
|
||||||
};
|
};
|
||||||
|
belongs_to_collection?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
poster_path?: string;
|
||||||
|
backdrop_path?: string;
|
||||||
|
};
|
||||||
external_ids: TmdbExternalIds;
|
external_ids: TmdbExternalIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,6 +350,15 @@ export interface TmdbSeasonWithEpisodes extends TmdbTvSeasonResult {
|
|||||||
external_ids: TmdbExternalIds;
|
external_ids: TmdbExternalIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TmdbCollection {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
overview?: string;
|
||||||
|
poster_path?: string;
|
||||||
|
backdrop_path?: string;
|
||||||
|
parts: TmdbMovieResult[];
|
||||||
|
}
|
||||||
|
|
||||||
class TheMovieDb {
|
class TheMovieDb {
|
||||||
private apiKey = 'db55323b8d3e4154498498a75642b381';
|
private apiKey = 'db55323b8d3e4154498498a75642b381';
|
||||||
private axios: AxiosInstance;
|
private axios: AxiosInstance;
|
||||||
@@ -866,6 +881,29 @@ class TheMovieDb {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getCollection({
|
||||||
|
collectionId,
|
||||||
|
language = 'en-US',
|
||||||
|
}: {
|
||||||
|
collectionId: number;
|
||||||
|
language?: string;
|
||||||
|
}): Promise<TmdbCollection> {
|
||||||
|
try {
|
||||||
|
const response = await this.axios.get<TmdbCollection>(
|
||||||
|
`/collection/${collectionId}`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
language,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TheMovieDb;
|
export default TheMovieDb;
|
||||||
|
29
server/models/Collection.ts
Normal file
29
server/models/Collection.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { TmdbCollection } from '../api/themoviedb';
|
||||||
|
import Media from '../entity/Media';
|
||||||
|
import { mapMovieResult, MovieResult } from './Search';
|
||||||
|
|
||||||
|
export interface Collection {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
overview?: string;
|
||||||
|
posterPath?: string;
|
||||||
|
backdropPath?: string;
|
||||||
|
parts: MovieResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mapCollection = (
|
||||||
|
collection: TmdbCollection,
|
||||||
|
media: Media[]
|
||||||
|
): Collection => ({
|
||||||
|
id: collection.id,
|
||||||
|
name: collection.name,
|
||||||
|
overview: collection.overview,
|
||||||
|
posterPath: collection.poster_path,
|
||||||
|
backdropPath: collection.backdrop_path,
|
||||||
|
parts: collection.parts.map((part) =>
|
||||||
|
mapMovieResult(
|
||||||
|
part,
|
||||||
|
media?.find((req) => req.tmdbId === part.id)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
});
|
@@ -46,6 +46,12 @@ export interface MovieDetails {
|
|||||||
cast: Cast[];
|
cast: Cast[];
|
||||||
crew: Crew[];
|
crew: Crew[];
|
||||||
};
|
};
|
||||||
|
collection?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
posterPath?: string;
|
||||||
|
backdropPath?: string;
|
||||||
|
};
|
||||||
mediaInfo?: Media;
|
mediaInfo?: Media;
|
||||||
externalIds: ExternalIds;
|
externalIds: ExternalIds;
|
||||||
}
|
}
|
||||||
@@ -87,6 +93,14 @@ export const mapMovieDetails = (
|
|||||||
cast: movie.credits.cast.map(mapCast),
|
cast: movie.credits.cast.map(mapCast),
|
||||||
crew: movie.credits.crew.map(mapCrew),
|
crew: movie.credits.crew.map(mapCrew),
|
||||||
},
|
},
|
||||||
|
collection: movie.belongs_to_collection
|
||||||
|
? {
|
||||||
|
id: movie.belongs_to_collection.id,
|
||||||
|
name: movie.belongs_to_collection.name,
|
||||||
|
posterPath: movie.belongs_to_collection.poster_path,
|
||||||
|
backdropPath: movie.belongs_to_collection.backdrop_path,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
externalIds: mapExternalIds(movie.external_ids),
|
externalIds: mapExternalIds(movie.external_ids),
|
||||||
mediaInfo: media,
|
mediaInfo: media,
|
||||||
});
|
});
|
||||||
|
27
server/routes/collection.ts
Normal file
27
server/routes/collection.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import TheMovieDb from '../api/themoviedb';
|
||||||
|
import Media from '../entity/Media';
|
||||||
|
import { mapCollection } from '../models/Collection';
|
||||||
|
|
||||||
|
const collectionRoutes = Router();
|
||||||
|
|
||||||
|
collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => {
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const collection = await tmdb.getCollection({
|
||||||
|
collectionId: Number(req.params.id),
|
||||||
|
language: req.query.language as string,
|
||||||
|
});
|
||||||
|
|
||||||
|
const media = await Media.getRelatedMedia(
|
||||||
|
collection.parts.map((part) => part.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(200).json(mapCollection(collection, media));
|
||||||
|
} catch (e) {
|
||||||
|
return next({ status: 404, message: 'Collection does not exist' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default collectionRoutes;
|
@@ -12,6 +12,7 @@ import movieRoutes from './movie';
|
|||||||
import tvRoutes from './tv';
|
import tvRoutes from './tv';
|
||||||
import mediaRoutes from './media';
|
import mediaRoutes from './media';
|
||||||
import personRoutes from './person';
|
import personRoutes from './person';
|
||||||
|
import collectionRoutes from './collection';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ router.use('/movie', isAuthenticated(), movieRoutes);
|
|||||||
router.use('/tv', isAuthenticated(), tvRoutes);
|
router.use('/tv', isAuthenticated(), tvRoutes);
|
||||||
router.use('/media', isAuthenticated(), mediaRoutes);
|
router.use('/media', isAuthenticated(), mediaRoutes);
|
||||||
router.use('/person', isAuthenticated(), personRoutes);
|
router.use('/person', isAuthenticated(), personRoutes);
|
||||||
|
router.use('/collection', isAuthenticated(), collectionRoutes);
|
||||||
router.use('/auth', authRoutes);
|
router.use('/auth', authRoutes);
|
||||||
|
|
||||||
router.get('/', (_req, res) => {
|
router.get('/', (_req, res) => {
|
||||||
|
267
src/components/CollectionDetails/index.tsx
Normal file
267
src/components/CollectionDetails/index.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import React, { useContext, useState } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
import { useToasts } from 'react-toast-notifications';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import { MediaStatus } from '../../../server/constants/media';
|
||||||
|
import type { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||||
|
import type { Collection } from '../../../server/models/Collection';
|
||||||
|
import { LanguageContext } from '../../context/LanguageContext';
|
||||||
|
import globalMessages from '../../i18n/globalMessages';
|
||||||
|
import Error from '../../pages/_error';
|
||||||
|
import Badge from '../Common/Badge';
|
||||||
|
import Button from '../Common/Button';
|
||||||
|
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||||
|
import Modal from '../Common/Modal';
|
||||||
|
import Slider from '../Slider';
|
||||||
|
import TitleCard from '../TitleCard';
|
||||||
|
import Transition from '../Transition';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
overviewunavailable: 'Overview unavailable',
|
||||||
|
overview: 'Overview',
|
||||||
|
movies: 'Movies',
|
||||||
|
numberofmovies: 'Number of Movies: {count}',
|
||||||
|
requesting: 'Requesting…',
|
||||||
|
request: 'Request',
|
||||||
|
requestcollection: 'Request Collection',
|
||||||
|
requestswillbecreated:
|
||||||
|
'The following titles will have requests created for them:',
|
||||||
|
requestSuccess: '<strong>{title}</strong> successfully requested!',
|
||||||
|
});
|
||||||
|
|
||||||
|
interface CollectionDetailsProps {
|
||||||
|
collection?: Collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CollectionDetails: React.FC<CollectionDetailsProps> = ({
|
||||||
|
collection,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const router = useRouter();
|
||||||
|
const { addToast } = useToasts();
|
||||||
|
const { locale } = useContext(LanguageContext);
|
||||||
|
const [requestModal, setRequestModal] = useState(false);
|
||||||
|
const [isRequesting, setRequesting] = useState(false);
|
||||||
|
const { data, error, revalidate } = useSWR<Collection>(
|
||||||
|
`/api/v1/collection/${router.query.collectionId}?language=${locale}`,
|
||||||
|
{
|
||||||
|
initialData: collection,
|
||||||
|
revalidateOnMount: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data && !error) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return <Error statusCode={404} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestableParts = data.parts.filter(
|
||||||
|
(part) => !part.mediaInfo || part.mediaInfo.status === MediaStatus.UNKNOWN
|
||||||
|
);
|
||||||
|
|
||||||
|
const requestBundle = async () => {
|
||||||
|
try {
|
||||||
|
setRequesting(true);
|
||||||
|
await Promise.all(
|
||||||
|
requestableParts.map(async (part) => {
|
||||||
|
await axios.post<MediaRequest>('/api/v1/request', {
|
||||||
|
mediaId: part.id,
|
||||||
|
mediaType: 'movie',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
addToast(
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage(messages.requestSuccess, {
|
||||||
|
title: data?.name,
|
||||||
|
strong: function strong(msg) {
|
||||||
|
return <strong>{msg}</strong>;
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
</span>,
|
||||||
|
{ appearance: 'success', autoDismiss: true }
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
addToast('Something went wrong requesting the collection.', {
|
||||||
|
appearance: 'error',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setRequesting(false);
|
||||||
|
setRequestModal(false);
|
||||||
|
revalidate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="px-4 pt-4 -mx-4 -mt-2 bg-center bg-cover sm:px-8 "
|
||||||
|
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})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Head>
|
||||||
|
<title>{data.name} - Overseerr</title>
|
||||||
|
</Head>
|
||||||
|
<Transition
|
||||||
|
enter="opacity-0 transition duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="opacity-100 transition duration-300"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
show={requestModal}
|
||||||
|
>
|
||||||
|
<Modal
|
||||||
|
onOk={() => requestBundle()}
|
||||||
|
okText={
|
||||||
|
isRequesting
|
||||||
|
? intl.formatMessage(messages.requesting)
|
||||||
|
: intl.formatMessage(messages.request)
|
||||||
|
}
|
||||||
|
okDisabled={isRequesting}
|
||||||
|
okButtonType="primary"
|
||||||
|
onCancel={() => setRequestModal(false)}
|
||||||
|
title={intl.formatMessage(messages.requestcollection)}
|
||||||
|
iconSvg={
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p>{intl.formatMessage(messages.requestswillbecreated)}</p>
|
||||||
|
<ul className="py-4 pl-8 list-disc">
|
||||||
|
{data.parts
|
||||||
|
.filter(
|
||||||
|
(part) =>
|
||||||
|
!part.mediaInfo ||
|
||||||
|
part.mediaInfo?.status === MediaStatus.UNKNOWN
|
||||||
|
)
|
||||||
|
.map((part) => (
|
||||||
|
<li key={`request-part-${part.id}`}>{part.title}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Modal>
|
||||||
|
</Transition>
|
||||||
|
<div className="flex flex-col items-center pt-4 md:flex-row md:items-end">
|
||||||
|
<div className="flex-shrink-0 md: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-52"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col mt-4 text-center text-white md:mr-4 md:mt-0 md:text-left">
|
||||||
|
<div className="mb-2">
|
||||||
|
{data.parts.every(
|
||||||
|
(part) => part.mediaInfo?.status === MediaStatus.AVAILABLE
|
||||||
|
) && (
|
||||||
|
<Badge badgeType="success">
|
||||||
|
{intl.formatMessage(globalMessages.available)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{!data.parts.every(
|
||||||
|
(part) => part.mediaInfo?.status === MediaStatus.AVAILABLE
|
||||||
|
) &&
|
||||||
|
data.parts.some(
|
||||||
|
(part) => part.mediaInfo?.status === MediaStatus.AVAILABLE
|
||||||
|
) && (
|
||||||
|
<Badge badgeType="success">
|
||||||
|
{intl.formatMessage(globalMessages.partiallyavailable)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl md:text-4xl">{data.name}</h1>
|
||||||
|
<span className="mt-1 text-xs md:text-base md:mt-0">
|
||||||
|
{intl.formatMessage(messages.numberofmovies, {
|
||||||
|
count: data.parts.length,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end flex-1 mt-4 md:mt-0">
|
||||||
|
{data.parts.some(
|
||||||
|
(part) =>
|
||||||
|
!part.mediaInfo || part.mediaInfo?.status === MediaStatus.UNKNOWN
|
||||||
|
) && (
|
||||||
|
<Button buttonType="primary" onClick={() => setRequestModal(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>
|
||||||
|
{intl.formatMessage(messages.requestcollection)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</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">
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
sliderKey="collection-movies"
|
||||||
|
isLoading={false}
|
||||||
|
isEmpty={data.parts.length === 0}
|
||||||
|
items={data.parts.map((title) => (
|
||||||
|
<TitleCard
|
||||||
|
key={`collection-movie-${title.id}`}
|
||||||
|
id={title.id}
|
||||||
|
image={title.posterPath}
|
||||||
|
status={title.mediaInfo?.status}
|
||||||
|
summary={title.overview}
|
||||||
|
title={title.title}
|
||||||
|
userScore={title.voteAverage}
|
||||||
|
year={title.releaseDate}
|
||||||
|
mediaType={title.mediaType}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
/>
|
||||||
|
<div className="pb-8" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CollectionDetails;
|
@@ -51,7 +51,7 @@ const Button: React.FC<ButtonProps> = ({
|
|||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
buttonStyle.push(
|
buttonStyle.push(
|
||||||
'leading-5 font-medium rounded-md text-gray-200 bg-gray-500 hover:bg-gray-400 hover:text-white focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-400 disabled:opacity-50'
|
'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'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -438,6 +438,25 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full mt-8 md:w-80 md:mt-0">
|
<div className="w-full mt-8 md:w-80 md:mt-0">
|
||||||
|
{data.collection && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link href={`/collection/${data.collection.id}`}>
|
||||||
|
<a>
|
||||||
|
<div
|
||||||
|
className="relative transition duration-300 transform scale-100 bg-gray-800 bg-center bg-cover rounded-lg shadow-md cursor-pointer group hover:scale-105"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(180deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 0.80) 100%), url(//image.tmdb.org/t/p/w1440_and_h320_multi_faces/${data.collection.backdropPath})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-4 text-gray-200 transition duration-300 h-14 group-hover:text-white">
|
||||||
|
<div>{data.collection.name}</div>
|
||||||
|
<Button buttonSize="sm">View</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-lg shadow">
|
<div className="bg-gray-900 border border-gray-800 rounded-lg shadow">
|
||||||
{(data.voteCount > 0 || ratingData) && (
|
{(data.voteCount > 0 || ratingData) && (
|
||||||
<div className="flex items-center justify-center px-4 py-2 border-b border-gray-800 last:border-b-0">
|
<div className="flex items-center justify-center px-4 py-2 border-b border-gray-800 last:border-b-0">
|
||||||
|
@@ -67,7 +67,11 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
|
|||||||
|
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
if (onComplete) {
|
if (onComplete) {
|
||||||
onComplete(response.data.media.status);
|
onComplete(
|
||||||
|
hasPermission(Permission.AUTO_APPROVE)
|
||||||
|
? MediaStatus.PROCESSING
|
||||||
|
: MediaStatus.PENDING
|
||||||
|
);
|
||||||
}
|
}
|
||||||
addToast(
|
addToast(
|
||||||
<span>
|
<span>
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
import type { MediaType } from '../../../server/models/Search';
|
import type { MediaType } from '../../../server/models/Search';
|
||||||
import Available from '../../assets/available.svg';
|
import Available from '../../assets/available.svg';
|
||||||
import Requested from '../../assets/requested.svg';
|
import Requested from '../../assets/requested.svg';
|
||||||
@@ -51,6 +51,10 @@ const TitleCard: React.FC<TitleCardProps> = ({
|
|||||||
year = year.slice(0, 4);
|
year = year.slice(0, 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentStatus(status);
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
const requestComplete = useCallback((newStatus: MediaStatus) => {
|
const requestComplete = useCallback((newStatus: MediaStatus) => {
|
||||||
setCurrentStatus(newStatus);
|
setCurrentStatus(newStatus);
|
||||||
setShowRequestModal(false);
|
setShowRequestModal(false);
|
||||||
@@ -74,7 +78,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
|
|||||||
onCancel={closeModal}
|
onCancel={closeModal}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="titleCard outline-none cursor-default"
|
className="outline-none cursor-default titleCard"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(//image.tmdb.org/t/p/w300_and_h450_face${image})`,
|
backgroundImage: `url(//image.tmdb.org/t/p/w300_and_h450_face${image})`,
|
||||||
}}
|
}}
|
||||||
@@ -93,13 +97,13 @@ const TitleCard: React.FC<TitleCardProps> = ({
|
|||||||
role="link"
|
role="link"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<div className="absolute top-0 h-full w-full bottom-0 left-0 right-0 overflow-hidden shadow-xl">
|
<div className="absolute top-0 bottom-0 left-0 right-0 w-full h-full overflow-hidden shadow-xl">
|
||||||
<div
|
<div
|
||||||
className={`absolute left-0 top-0 rounded-tl-md rounded-br-md z-40 ${
|
className={`absolute left-0 top-0 rounded-tl-md rounded-br-md z-40 ${
|
||||||
mediaType === 'movie' ? 'bg-blue-500' : 'bg-purple-600'
|
mediaType === 'movie' ? 'bg-blue-500' : 'bg-purple-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center text-center text-xs text-white h-4 px-2 py-1 font-normal uppercase">
|
<div className="flex items-center h-4 px-2 py-1 text-xs font-normal text-center text-white uppercase">
|
||||||
{mediaType === 'movie'
|
{mediaType === 'movie'
|
||||||
? intl.formatMessage(messages.movie)
|
? intl.formatMessage(messages.movie)
|
||||||
: intl.formatMessage(messages.tvshow)}
|
: intl.formatMessage(messages.tvshow)}
|
||||||
@@ -107,7 +111,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="absolute right-0 top-0 z-40"
|
className="absolute top-0 right-0 z-40"
|
||||||
style={{
|
style={{
|
||||||
right: '-1px',
|
right: '-1px',
|
||||||
}}
|
}}
|
||||||
@@ -132,7 +136,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
|
|||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<div className="absolute top-0 left-0 right-0 bottom-0 bg-gray-800 bg-opacity-75 z-40 text-white flex items-center justify-center rounded-lg">
|
<div className="absolute top-0 bottom-0 left-0 right-0 z-40 flex items-center justify-center text-white bg-gray-800 bg-opacity-75 rounded-lg">
|
||||||
<svg
|
<svg
|
||||||
className="w-10 h-10 animate-spin"
|
className="w-10 h-10 animate-spin"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -162,14 +166,14 @@ const TitleCard: React.FC<TitleCardProps> = ({
|
|||||||
<div>
|
<div>
|
||||||
<Link href={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}>
|
<Link href={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}>
|
||||||
<a
|
<a
|
||||||
className="absolute w-full h-full text-left top-0 right-0 left-0 bottom-0 rounded-lg overflow-hidden cursor-pointer"
|
className="absolute top-0 bottom-0 left-0 right-0 w-full h-full overflow-hidden text-left rounded-lg cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
background:
|
background:
|
||||||
'linear-gradient(180deg, rgba(45, 55, 72, 0.4) 0%, rgba(45, 55, 72, 0.9) 100%)',
|
'linear-gradient(180deg, rgba(45, 55, 72, 0.4) 0%, rgba(45, 55, 72, 0.9) 100%)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-end h-full w-full">
|
<div className="flex items-end w-full h-full">
|
||||||
<div className="px-2 pb-11 text-white">
|
<div className="px-2 text-white pb-11">
|
||||||
{year && <div className="text-sm">{year}</div>}
|
{year && <div className="text-sm">{year}</div>}
|
||||||
|
|
||||||
<h1 className="text-xl leading-tight whitespace-normal">
|
<h1 className="text-xl leading-tight whitespace-normal">
|
||||||
@@ -191,11 +195,11 @@ const TitleCard: React.FC<TitleCardProps> = ({
|
|||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="absolute flex justify-between left-0 bottom-0 right-0 px-2 py-2">
|
<div className="absolute bottom-0 left-0 right-0 flex justify-between px-2 py-2">
|
||||||
<Link
|
<Link
|
||||||
href={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}
|
href={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}
|
||||||
>
|
>
|
||||||
<a className="cursor-pointer flex w-full h-7 text-center text-white bg-indigo-500 rounded-sm hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 transition ease-in-out duration-150">
|
<a className="flex w-full text-center text-white transition duration-150 ease-in-out bg-indigo-500 rounded-sm cursor-pointer h-7 hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700">
|
||||||
<svg
|
<svg
|
||||||
className="w-4 mx-auto"
|
className="w-4 mx-auto"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -224,7 +228,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setShowRequestModal(true);
|
setShowRequestModal(true);
|
||||||
}}
|
}}
|
||||||
className="w-full h-7 text-center text-white bg-indigo-500 rounded-sm ml-2 hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 transition ease-in-out duration-150"
|
className="w-full ml-2 text-center text-white transition duration-150 ease-in-out bg-indigo-500 rounded-sm h-7 hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-4 mx-auto"
|
className="w-4 mx-auto"
|
||||||
@@ -244,7 +248,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
{currentStatus === MediaStatus.PENDING && (
|
{currentStatus === MediaStatus.PENDING && (
|
||||||
<button
|
<button
|
||||||
className="w-full h-7 text-center text-yellow-500 border border-yellow-500 rounded-sm ml-2 cursor-default"
|
className="w-full ml-2 text-center text-yellow-500 border border-yellow-500 rounded-sm cursor-default h-7"
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -265,7 +269,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
{currentStatus === MediaStatus.PROCESSING && (
|
{currentStatus === MediaStatus.PROCESSING && (
|
||||||
<button
|
<button
|
||||||
className="w-full h-7 text-center text-red-500 border border-red-500 rounded-sm ml-2 cursor-default"
|
className="w-full ml-2 text-center text-red-500 border border-red-500 rounded-sm cursor-default h-7"
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -287,7 +291,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
|
|||||||
{(currentStatus === MediaStatus.AVAILABLE ||
|
{(currentStatus === MediaStatus.AVAILABLE ||
|
||||||
currentStatus === MediaStatus.PARTIALLY_AVAILABLE) && (
|
currentStatus === MediaStatus.PARTIALLY_AVAILABLE) && (
|
||||||
<button
|
<button
|
||||||
className="w-full h-7 text-center text-green-400 border border-green-400 rounded-sm ml-2 cursor-default"
|
className="w-full ml-2 text-center text-green-400 border border-green-400 rounded-sm cursor-default h-7"
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
@@ -1,4 +1,13 @@
|
|||||||
{
|
{
|
||||||
|
"components.CollectionDetails.movies": "Movies",
|
||||||
|
"components.CollectionDetails.numberofmovies": "Number of Movies: {count}",
|
||||||
|
"components.CollectionDetails.overview": "Overview",
|
||||||
|
"components.CollectionDetails.overviewunavailable": "Overview unavailable",
|
||||||
|
"components.CollectionDetails.request": "Request",
|
||||||
|
"components.CollectionDetails.requestSuccess": "<strong>{title}</strong> successfully requested!",
|
||||||
|
"components.CollectionDetails.requestcollection": "Request Collection",
|
||||||
|
"components.CollectionDetails.requesting": "Requesting…",
|
||||||
|
"components.CollectionDetails.requestswillbecreated": "The following titles will have requests created for them:",
|
||||||
"components.Discover.discovermovies": "Popular Movies",
|
"components.Discover.discovermovies": "Popular Movies",
|
||||||
"components.Discover.discovertv": "Popular Series",
|
"components.Discover.discovertv": "Popular Series",
|
||||||
"components.Discover.nopending": "No Pending Requests",
|
"components.Discover.nopending": "No Pending Requests",
|
||||||
|
38
src/pages/collection/[collectionId]/index.tsx
Normal file
38
src/pages/collection/[collectionId]/index.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { GetServerSideProps, NextPage } from 'next';
|
||||||
|
import type { Collection } from '../../../../server/models/Collection';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { parseCookies } from 'nookies';
|
||||||
|
import CollectionDetails from '../../../components/CollectionDetails';
|
||||||
|
|
||||||
|
interface CollectionPageProps {
|
||||||
|
collection?: Collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CollectionPage: NextPage<CollectionPageProps> = ({ collection }) => {
|
||||||
|
return <CollectionDetails collection={collection} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getServerSideProps: GetServerSideProps<CollectionPageProps> = async (
|
||||||
|
ctx
|
||||||
|
) => {
|
||||||
|
const cookies = parseCookies(ctx);
|
||||||
|
const response = await axios.get<Collection>(
|
||||||
|
`http://localhost:${process.env.PORT || 5055}/api/v1/collection/${
|
||||||
|
ctx.query.collectionId
|
||||||
|
}${cookies.locale ? `?language=${cookies.locale}` : ''}`,
|
||||||
|
{
|
||||||
|
headers: ctx.req?.headers?.cookie
|
||||||
|
? { cookie: ctx.req.headers.cookie }
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
collection: response.data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CollectionPage;
|
Reference in New Issue
Block a user