Refactor the code in reusable components and libs

This commit is contained in:
Grégoire Delattre 2019-07-04 17:26:56 +02:00
parent 88fc8be462
commit 26499533d3
40 changed files with 978 additions and 970 deletions

View File

@ -1,28 +1,24 @@
import { configureAxios, request } from "../requests" import { configureAxios, request } from "../requests"
export function refreshSubtitles(type, id, season, episode) { export const searchMovieSubtitles = (imdbId) => {
switch (type) {
case "movie":
var resourceURL = `/movies/${id}`
return request( return request(
"MOVIE_SUBTITLES_UPDATE", "MOVIE_SUBTITLES_UPDATE",
configureAxios().post(`${resourceURL}/subtitles/refresh`), configureAxios().post(`/movies/${imdbId}/subtitles/refresh`),
null, null,
{ imdbId: id }, { imdbId: imdbId },
) )
case "episode": }
var resourceURL = `/shows/${id}/seasons/${season}/episodes/${episode}`
export const searchEpisodeSubtitles = (imdbId, season, episode) => {
const url = `/shows/${imdbId}/seasons/${season}/episodes/${episode}`;
return request( return request(
"EPISODE_SUBTITLES_UPDATE", "EPISODE_SUBTITLES_UPDATE",
configureAxios().post(`${resourceURL}/subtitles/refresh`), configureAxios().post(`${url}/subtitles/refresh`),
null, null,
{ {
imdbId: id, imdbId: imdbId,
season: season, season: season,
episode: episode, episode: episode,
}, },
) );
default:
console.warn("refreshSubtitles - Unknown type " + type)
}
} }

View File

@ -31,7 +31,7 @@ import MovieList from "./components/movies/list"
import MoviesRoute from "./components/movies/route" import MoviesRoute from "./components/movies/route"
import NavBar from "./components/navbar" import NavBar from "./components/navbar"
import WsHandler from "./components/websocket" import WsHandler from "./components/websocket"
import ShowDetails from "./components/shows/details" import { ShowDetails } from "./components/shows/details"
import ShowList from "./components/shows/list" import ShowList from "./components/shows/list"
import ShowsRoute from "./components/shows/route" import ShowsRoute from "./components/shows/route"
import TorrentList from "./components/torrents/list" import TorrentList from "./components/torrents/list"

View File

@ -1,70 +0,0 @@
import React from "react"
import PropTypes from "prop-types"
import Dropdown from "react-bootstrap/Dropdown"
import RefreshIndicator from "./refresh"
export const WishlistButton = (props) => {
const handleClick = (e) => {
e.preventDefault();
if (props.wishlisted) {
props.deleteFromWishlist(props.resourceId);
} else {
props.addToWishlist(props.resourceId);
}
}
if (props.wishlisted) {
return (
<Dropdown.Item onClick={handleClick}>
<span>
<i className="fa fa-bookmark"></i> Delete from wishlist
</span>
</Dropdown.Item>
);
} else {
return (
<Dropdown.Item onClick={handleClick}>
<span>
<i className="fa fa-bookmark-o"></i> Add to wishlist
</span>
</Dropdown.Item>
);
}
}
export const DeleteButton = (props) => {
const handleClick = () => {
props.deleteFunc(props.resourceId, props.lastFetchUrl);
}
return (
<Dropdown.Item onClick={handleClick}>
<span>
<i className="fa fa-trash"></i> Delete
</span>
</Dropdown.Item>
);
}
DeleteButton.propTypes = {
resourceId: PropTypes.string.isRequired,
lastFetchUrl: PropTypes.string,
deleteFunc: PropTypes.func.isRequired,
};
export const RefreshButton = (props) => {
const handleClick = () => {
if (props.fetching) { return; }
props.getDetails(props.resourceId);
}
return (
<Dropdown.Item onClick={handleClick}>
<RefreshIndicator refresh={props.fetching} />
</Dropdown.Item>
);
}
RefreshButton.propTypes = {
fetching: PropTypes.bool.isRequired,
resourceId: PropTypes.string.isRequired,
getDetails: PropTypes.func.isRequired,
};

View File

@ -8,10 +8,10 @@ export const DownloadAndStream = ({ url, name, subtitles }) => {
if (!url || url === "") { return null; } if (!url || url === "") { return null; }
return ( return (
<React.Fragment> <span>
<DownloadButton url={url} /> <DownloadButton url={url} />
<StreamButton url={url} name={name} subtitles={subtitles} /> <StreamButton url={url} name={name} subtitles={subtitles} />
</React.Fragment> </span>
); );
} }
DownloadAndStream.propTypes = { DownloadAndStream.propTypes = {
@ -58,9 +58,8 @@ StreamButton.propTypes = {
subtitles: PropTypes.instanceOf(List), subtitles: PropTypes.instanceOf(List),
}; };
const Player = ({ url, subtitles }) => { const Player = ({ url, subtitles }) => {
const hasSubtitles = !(subtitles === undefined || subtitles === null || subtitles.size === 0); const hasSubtitles = !(subtitles === null || subtitles.size === 0);
return ( return (
<div className="embed-responsive embed-responsive-16by9"> <div className="embed-responsive embed-responsive-16by9">
<video className="embed-responsive-item" controls> <video className="embed-responsive-item" controls>
@ -82,3 +81,6 @@ Player.propTypes = {
subtitles: PropTypes.instanceOf(List), subtitles: PropTypes.instanceOf(List),
url: PropTypes.string.isRequired, url: PropTypes.string.isRequired,
}; };
Player.defaultProps = {
subtitles: List(),
};

View File

@ -1,29 +0,0 @@
import React from "react"
import PropTypes from "prop-types"
export const PolochonMetadata = (props) => {
if (!props.quality || props.quality === "") {
return null;
}
const badgeStyle = (props.light) ? "light" : "secondary";
const className = `mx-1 badge badge-pill badge-${badgeStyle}`;
return (
<React.Fragment>
<span className={className}>{props.quality}</span>
<span className={className}>{props.container} </span>
<span className={className}>{props.videoCodec}</span>
<span className={className}>{props.audioCodec}</span>
<span className={className}>{props.releaseGroup}</span>
</React.Fragment>
);
}
PolochonMetadata.propTypes = {
light: PropTypes.bool,
quality: PropTypes.string,
container: PropTypes.string,
videoCodec: PropTypes.string,
audioCodec: PropTypes.string,
releaseGroup: PropTypes.string,
};

View File

@ -1,23 +0,0 @@
import React from "react"
import PropTypes from "prop-types"
const RefreshIndicator = (props) => {
if (!props.refresh) {
return (
<span>
<i className="fa fa-refresh"></i> Refresh
</span>
);
} else {
return (
<span>
<i className="fa fa-spin fa-refresh"></i> Refreshing
</span>
);
}
}
RefreshIndicator.propTypes = {
refresh: PropTypes.bool.isRequired,
};
export default RefreshIndicator;

View File

@ -0,0 +1,38 @@
import React, { useState, useEffect } from "react"
import PropTypes from "prop-types"
export const ShowMore = ({ children, id, inLibrary }) => {
const [ display, setDisplay ] = useState(!inLibrary)
useEffect(() => {
setDisplay(!inLibrary);
}, [id, inLibrary]);
if (!display) {
return (
<span>
<a
onClick={() => setDisplay(true)}
className="badge badge-pill badge-info clickable"
>
More options ...
</a>
</span>
)
}
return (<React.Fragment>{children}</React.Fragment>)
}
ShowMore.propTypes = {
id: PropTypes.string,
inLibrary: PropTypes.bool.isRequired,
children: PropTypes.oneOf(
PropTypes.object,
PropTypes.array,
),
}
ShowMore.defaultProps = {
id: "",
inLibrary: false,
}

View File

@ -4,75 +4,57 @@ import { List } from "immutable"
import Dropdown from "react-bootstrap/Dropdown" import Dropdown from "react-bootstrap/Dropdown"
import RefreshIndicator from "./refresh" export const SubtitlesButton = ({
subtitles,
const SubtitlesButton = (props) => { inLibrary,
const subtitles = props.subtitles; searching,
const hasSubtitles = !(subtitles === undefined || subtitles === null || subtitles.size === 0); search,
const size = props.xs ? "sm" : ""; }) => {
if (inLibrary === false) { return null }
const count = (subtitles && subtitles.size !== 0) ? subtitles.size : 0;
return ( return (
<span className="mr-1 mb-1">
<Dropdown drop="up"> <Dropdown drop="up">
<Dropdown.Toggle size={size} variant="success" id="movie-subtitles"> <Dropdown.Toggle variant="success">
<i className="fa fa-language mr-1" />
Subtitles Subtitles
<span className="ml-1 badge badge-pill badge-light">
{count}
</span>
</Dropdown.Toggle> </Dropdown.Toggle>
<Dropdown.Menu> <Dropdown.Menu>
<RefreshButton <Dropdown.Header>
type={props.type} <span className="text-warning">Advanced</span>
resourceID={props.resourceID} </Dropdown.Header>
season={props.season} <Dropdown.Item onClick={search} >
episode={props.episode} <i className={`fa ${ searching ? "fa-spin" : "" } fa-refresh mr-1`} />
fetching={props.fetching ? true : false} Automatic search
refreshSubtitles={props.refreshSubtitles}
/>
{hasSubtitles &&
<Dropdown.Divider />
}
{hasSubtitles && subtitles.toIndexedSeq().map(function(subtitle, index) {
return (
<Dropdown.Item key={index} href={subtitle.get("url")}>
<i className="fa fa-download"></i> &nbsp;{subtitle.get("language").split("_")[1]}
</Dropdown.Item> </Dropdown.Item>
); {count > 0 &&
})} <React.Fragment>
<Dropdown.Divider />
<Dropdown.Header>
<span className="text-warning">
Available subtitles
</span>
</Dropdown.Header>
</React.Fragment>
}
{count > 0 && subtitles.toIndexedSeq().map((subtitle, index) => (
<Dropdown.Item href={subtitle.get("url")} key={index}>
{subtitle.get("language").split("_")[1]}
</Dropdown.Item>
))}
</Dropdown.Menu> </Dropdown.Menu>
</Dropdown> </Dropdown>
</span>
); );
} }
SubtitlesButton.propTypes = { SubtitlesButton.propTypes = {
subtitles: PropTypes.instanceOf(List), subtitles: PropTypes.instanceOf(List),
xs: PropTypes.bool, inLibrary: PropTypes.bool.isRequired,
fetching: PropTypes.bool, searching: PropTypes.bool.isRequired,
refreshSubtitles: PropTypes.func.isRequired, search: PropTypes.func.isRequired,
type: PropTypes.string.isRequired,
resourceID: PropTypes.string.isRequired,
season: PropTypes.number,
episode: PropTypes.number,
}
export default SubtitlesButton;
const RefreshButton = (props) => {
const handleClick = () => {
if (props.fetching) {
return
}
props.refreshSubtitles(props.type, props.resourceID,
props.season, props.episode);
}
return (
<Dropdown.Item onClick={handleClick}>
<RefreshIndicator refresh={props.fetching} />
</Dropdown.Item>
);
}
RefreshButton.propTypes = {
fetching: PropTypes.bool,
refreshSubtitles: PropTypes.func.isRequired,
type: PropTypes.string.isRequired,
resourceID: PropTypes.string.isRequired,
season: PropTypes.number,
episode: PropTypes.number,
} }

View File

@ -0,0 +1,117 @@
import React from "react"
import PropTypes from "prop-types"
import { List } from "immutable"
import { connect } from "react-redux"
import { addTorrent } from "../../actions/torrents"
import Dropdown from "react-bootstrap/Dropdown"
function buildMenuItems(torrents) {
if (!torrents || torrents.size === 0) {
return [];
}
const t = torrents.groupBy((el) => el.get("source"));
// Build the array of entries
let entries = [];
let dividerCount = t.size - 1;
for (let [source, torrentList] of t.entrySeq()) {
// Push the title
entries.push({
type: "header",
value: source,
});
// Push the torrents
for (let torrent of torrentList) {
entries.push({
type: "entry",
quality: torrent.get("quality"),
url: torrent.get("url"),
});
}
// Push the divider
if (dividerCount > 0) {
dividerCount--;
entries.push({ type: "divider" });
}
}
return entries;
}
const torrentsButton = ({
torrents,
search,
searching,
addTorrent,
url,
}) => {
const entries = buildMenuItems(torrents);
const count = (torrents && torrents.size !== 0) ? torrents.size : 0;
return (
<span className="mr-1 mb-1">
<Dropdown drop="up">
<Dropdown.Toggle variant="primary">
<i className="fa fa-magnet mr-1" />
Torrents
<span className="ml-1 badge badge-pill badge-light">
{count}
</span>
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Header>
<span className="text-warning">Advanced</span>
</Dropdown.Header>
<Dropdown.Item onClick={search} >
<i className={`fa ${ searching ? "fa-spin" : "" } fa-refresh mr-1`} />
Automatic search
</Dropdown.Item>
<Dropdown.Item href={url} >
<i className="fa fa-search mr-1" />
Manual search
</Dropdown.Item>
{entries.length > 0 &&
<Dropdown.Divider />
}
{entries.map((e, index) => {
switch (e.type) {
case "header":
return (
<Dropdown.Header key={index}>
<span className="text-warning">{e.value}</span>
</Dropdown.Header>
);
case "divider":
return (
<Dropdown.Divider key={index}/>
);
case "entry":
return (
<Dropdown.Item key={index} onClick={() => addTorrent(e.url)}>
{e.quality}
</Dropdown.Item>
);
}
})}
</Dropdown.Menu>
</Dropdown>
</span>
);
}
torrentsButton.propTypes = {
torrents: PropTypes.instanceOf(List),
searching: PropTypes.bool,
search: PropTypes.func.isRequired,
addTorrent: PropTypes.func.isRequired,
url: PropTypes.string,
}
torrentsButton.defaultProps = {
torrents: List(),
}
export const TorrentsButton = connect(null, {addTorrent})(torrentsButton);

View File

@ -0,0 +1,21 @@
import React from "react"
import PropTypes from "prop-types"
import { List } from "immutable"
export const Genres = ({ genres }) => {
if (genres.size === 0) { return null }
// Uppercase first genres
const prettyGenres = genres.toJS().map(
(word) => word[0].toUpperCase() + word.substr(1)
).join(", ");
return (
<span>
<i className="fa fa-tags mr-1" />
{prettyGenres}
</span>
);
}
Genres.propTypes = { genres: PropTypes.instanceOf(List) };
Genres.defaultProps = { genres: List() };

View File

@ -0,0 +1,12 @@
import React from "react"
import PropTypes from "prop-types"
export const Plot = ({ plot }) => {
if (plot === "") { return null }
return (
<span className="text text-break plot">{plot}</span>
);
}
Plot.propTypes = { plot: PropTypes.string };
Plot.defaultProps = { plot: "" };

View File

@ -0,0 +1,38 @@
import React from "react"
import PropTypes from "prop-types"
export const PolochonMetadata = ({
quality,
container,
videoCodec,
audioCodec,
releaseGroup,
}) => {
if (!quality || quality === "") {
return null;
}
const metadata = [
quality,
container,
videoCodec,
audioCodec,
releaseGroup,
].
filter(m => (m && m !== "")).
join(", ")
return (
<span>
<i className="fa fa-file-video-o mr-1" />
{metadata}
</span>
);
}
PolochonMetadata.propTypes = {
quality: PropTypes.string,
container: PropTypes.string,
videoCodec: PropTypes.string,
audioCodec: PropTypes.string,
releaseGroup: PropTypes.string,
};

View File

@ -0,0 +1,24 @@
import React from "react"
import PropTypes from "prop-types"
export const Rating = ({ rating, votes }) => {
if (rating === 0) { return null; }
return (
<span>
<i className="fa fa-star-o mr-1"></i>
{Number(rating).toFixed(1)}
{votes !== 0 &&
<small className="ml-1">({votes} votes)</small>
}
</span>
);
}
Rating.propTypes = {
rating: PropTypes.number,
votes: PropTypes.number,
};
Rating.defaultProps = {
rating: 0,
votes: 0,
};

View File

@ -0,0 +1,47 @@
import React from "react"
import PropTypes from "prop-types"
import moment from "moment"
const prettyDate = (input) => {
switch (typeof input) {
case "string":
if (input === "") { return "" }
break;
case "number":
if (input === 0) { return "" }
return input
default:
return input;
}
const date = moment(input);
if (!date.isValid()) { return "" }
let output = date.format("DD/MM/YYYY");
if ((date > moment().subtract(1, "month"))
&& (date < moment().add(1, "month"))) {
output += " (" + date.fromNow() + ")"
}
return output
}
export const ReleaseDate = ({ date }) => {
const formattedDate = prettyDate(date);
if (formattedDate === "") { return null }
return (
<span>
<i className="fa fa-calendar mr-1" />
{formattedDate}
</span>
);
}
ReleaseDate.propTypes = {
date: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]),
};
ReleaseDate.defaultProps = { date: "" };

View File

@ -0,0 +1,17 @@
import React from "react"
import PropTypes from "prop-types"
import { prettyDurationFromMinutes } from "../../utils"
export const Runtime = ({ runtime }) => {
if (runtime === 0) { return null; }
return (
<span>
<i className="fa fa-clock-o"></i>
&nbsp;{prettyDurationFromMinutes(runtime)}
</span>
);
}
Runtime.propTypes = { runtime: PropTypes.number };
Runtime.defaultProps = { runtime: 0 };

View File

@ -0,0 +1,25 @@
import React from "react"
import PropTypes from "prop-types"
export const Title = ({ title, xs }) => {
if (title === "") { return null; }
if (xs) {
return (<h5>{title}</h5>)
} else {
return (
<span>
<h2 className="d-none d-sm-block">{title}</h2>
<h4 className="d-block d-sm-none">{title}</h4>
</span>
);
}
}
Title.propTypes = {
title: PropTypes.string,
xs: PropTypes.bool,
};
Title.defaultProps = {
title: "",
xs: false,
};

View File

@ -1,30 +1,35 @@
import React from "react" import React from "react"
import { Map, List } from "immutable"
import PropTypes from "prop-types" import PropTypes from "prop-types"
import { Map } from "immutable"
import { PolochonMetadata } from "../buttons/polochon"
import { DownloadAndStream } from "../buttons/download" import { DownloadAndStream } from "../buttons/download"
import { ImdbBadge } from "../buttons/imdb" import { ImdbBadge } from "../buttons/imdb"
import { Genres } from "../details/genres"
import { Plot } from "../details/plot"
import { PolochonMetadata } from "../details/polochon"
import { Rating } from "../details/rating"
import { ReleaseDate } from "../details/releaseDate"
import { Runtime } from "../details/runtime"
import { Title } from "../details/title"
const ListDetails = (props) => { const ListDetails = (props) => {
if (props.loading) { return null }
if (props.data === undefined) { return null } if (props.data === undefined) { return null }
if (props.loading) { return null }
return ( return (
<div className="col-8 col-md-4 list-details pl-1 d-flex align-items-start flex-column"> <div className="col-8 col-md-4 list-details pl-1 d-flex align-items-start flex-column">
<div className="video-details flex-fill d-flex flex-column"> <div className="video-details flex-fill d-flex flex-column">
<h2 className="d-none d-sm-block">{props.data.get("title")}</h2> <Title title={props.data.get("title")} />
<h4 className="d-block d-sm-none">{props.data.get("title")}</h4>
<TrackingLabel <TrackingLabel
wishlisted={props.data.get("wishlisted")} wishlisted={props.data.get("wishlisted")}
trackedSeason={props.data.get("tracked_season")} trackedSeason={props.data.get("tracked_season")}
trackedEpisode={props.data.get("tracked_episode")} trackedEpisode={props.data.get("tracked_episode")}
/> />
<h4 className="d-none d-sm-block">{props.data.get("year")}</h4> <ReleaseDate date={props.data.get("year")} />
<h5 className="d-block d-sm-none">{props.data.get("year")}</h5>
<Runtime runtime={props.data.get("runtime")} /> <Runtime runtime={props.data.get("runtime")} />
<Genres genres={props.data.get("genres")} /> <Genres genres={props.data.get("genres")} />
<Ratings <Rating
rating={props.data.get("rating")} rating={props.data.get("rating")}
votes={props.data.get("votes")} votes={props.data.get("votes")}
/> />
@ -36,7 +41,6 @@ const ListDetails = (props) => {
subtitles={props.data.get("subtitles")} subtitles={props.data.get("subtitles")}
/> />
</div> </div>
<div>
<PolochonMetadata <PolochonMetadata
quality={props.data.get("quality")} quality={props.data.get("quality")}
releaseGroup={props.data.get("release_group")} releaseGroup={props.data.get("release_group")}
@ -44,10 +48,7 @@ const ListDetails = (props) => {
audioCodec={props.data.get("audio_codec")} audioCodec={props.data.get("audio_codec")}
videoCodec={props.data.get("video_codec")} videoCodec={props.data.get("video_codec")}
/> />
</div> <Plot plot={props.data.get("plot")} />
<p className="text text-break plot">{props.data.get("plot")}</p>
</div>
<div className="pb-1 align-self-end">
{props.children} {props.children}
</div> </div>
</div> </div>
@ -60,48 +61,6 @@ ListDetails.propTypes = {
}; };
export default ListDetails; export default ListDetails;
const Runtime = (props) => {
if (props.runtime === undefined || props.runtime === 0) {
return null;
}
const hours = Math.floor(props.runtime / 60);
const minutes = (props.runtime % 60);
let duration = "";
if (hours > 0) { duration += hours + "h" }
if (minutes > 0) { duration += ("0" + minutes).slice(-2) }
if (hours === 0) { duration += " min" }
return (
<p>
<i className="fa fa-clock-o"></i>
&nbsp;{duration}
</p>
);
}
Runtime.propTypes = { runtime: PropTypes.number };
const Ratings = (props) => {
if (props.rating === undefined) {
return null;
}
return (
<p>
<i className="fa fa-star-o"></i>
&nbsp;{Number(props.rating).toFixed(1)}&nbsp;
{props.votes !== undefined &&
<small>({props.votes} counts)</small>
}
</p>
);
}
Ratings.propTypes = {
rating: PropTypes.number,
votes: PropTypes.number,
};
const TrackingLabel = (props) => { const TrackingLabel = (props) => {
let wishlistStr = props.wishlisted ? "Wishlisted" : ""; let wishlistStr = props.wishlisted ? "Wishlisted" : "";
@ -131,24 +90,3 @@ TrackingLabel.propTypes = {
trackedSeason: PropTypes.number, trackedSeason: PropTypes.number,
trackedEpisode: PropTypes.number, trackedEpisode: PropTypes.number,
}; };
const Genres = (props) => {
if ((props.genres === undefined) || (props.genres.size === 0)) {
return null;
}
// Uppercase first genres
const prettyGenres = props.genres.toJS().map(
(word) => word[0].toUpperCase() + word.substr(1)
).join(", ");
return (
<p>
<i className="fa fa-tags"></i>
&nbsp;{prettyGenres}
</p>
);
}
Genres.propTypes = {
genres: PropTypes.instanceOf(List),
};

View File

@ -1,20 +1,20 @@
import React, { useState, useEffect } from "react" import React, { useState, useEffect } from "react"
import PropTypes from "prop-types" import PropTypes from "prop-types"
const ListFilter = (props) => { const ListFilter = ({ placeHolder, updateFilter }) => {
const [filter, setFilter] = useState(""); const [filter, setFilter] = useState("");
useEffect(() => { useEffect(() => {
// Start filtering at 3 chars // Start filtering at 3 chars
if (filter.length >= 3) { if (filter.length >= 3) {
props.updateFilter(filter); updateFilter(filter);
} }
}, [filter]); }, [filter, updateFilter]);
return ( return (
<div className="input-group input-group-sm"> <div className="input-group input-group-sm">
<input type="text" className="form-control" <input type="text" className="form-control"
placeholder={props.placeHolder} placeholder={placeHolder}
onChange={(e) => setFilter(e.target.value)} onChange={(e) => setFilter(e.target.value)}
value={filter} /> value={filter} />
<div className="input-group-append d-none d-md-block"> <div className="input-group-append d-none d-md-block">

View File

@ -1,25 +1,24 @@
import React from "react" import React from "react"
import PropTypes from "prop-types" import PropTypes from "prop-types"
import { Map } from "immutable"
import EmptyImg from "../../../img/noimage.png" import EmptyImg from "../../../img/noimage.png"
const Poster = (props) => { const Poster = ({ url, selected, onClick, onDoubleClick }) => {
const className = props.selected ? "border-primary thumbnail-selected" : "border-secondary"; const className = selected ? "border-primary thumbnail-selected" : "border-secondary";
const src = (props.data.get("poster_url") === "") ? EmptyImg : props.data.get("poster_url"); const src = (url === "") ? EmptyImg : url;
return ( return (
<img <img
src={src} src={src}
onClick={props.onClick} onClick={onClick}
onDoubleClick={props.onDoubleClick} onDoubleClick={onDoubleClick}
className={`my-1 m-md-2 img-thumbnail object-fit-cover ${className}`} className={`my-1 m-md-2 img-thumbnail object-fit-cover ${className}`}
/> />
); );
} }
Poster.propTypes = { Poster.propTypes = {
data: PropTypes.instanceOf(Map), url: PropTypes.string,
selected: PropTypes.bool, selected: PropTypes.bool.isRequired,
onClick: PropTypes.func, onClick: PropTypes.func,
onDoubleClick: PropTypes.func, onDoubleClick: PropTypes.func,
}; };

View File

@ -201,8 +201,8 @@ const Posters = (props) => {
return ( return (
<Poster <Poster
data={el} url={el.get("poster_url")}
key={`poster-${imdbId}-${index}`} key={index}
selected={selected} selected={selected}
onClick={() => props.selectPoster(imdbId)} onClick={() => props.selectPoster(imdbId)}
onDoubleClick={() => props.onDoubleClick(imdbId)} onDoubleClick={() => props.onDoubleClick(imdbId)}

View File

@ -1,47 +0,0 @@
import React from "react"
import PropTypes from "prop-types"
import { WishlistButton, DeleteButton, RefreshButton } from "../buttons/actions"
import Dropdown from "react-bootstrap/Dropdown"
const ActionsButton = (props) => (
<Dropdown drop="up">
<Dropdown.Toggle variant="secondary" id="movie-button-actions">
Actions
</Dropdown.Toggle>
<Dropdown.Menu>
<RefreshButton
fetching={props.fetching}
resourceId={props.movieId}
getDetails={props.getDetails}
/>
{props.hasMovie &&
<DeleteButton
resourceId={props.movieId}
lastFetchUrl={props.lastFetchUrl}
deleteFunc={props.deleteMovie}
/>
}
<WishlistButton
resourceId={props.movieId}
wishlisted={props.wishlisted}
addToWishlist={props.addToWishlist}
deleteFromWishlist={props.deleteFromWishlist}
/>
</Dropdown.Menu>
</Dropdown>
);
ActionsButton.propTypes = {
hasMovie: PropTypes.bool.isRequired,
fetching: PropTypes.bool.isRequired,
wishlisted: PropTypes.bool.isRequired,
movieId: PropTypes.string.isRequired,
getDetails: PropTypes.func.isRequired,
addToWishlist: PropTypes.func.isRequired,
deleteFromWishlist: PropTypes.func.isRequired,
deleteMovie: PropTypes.func.isRequired,
lastFetchUrl: PropTypes.string.isRequired,
}
export default ActionsButton;

View File

@ -1,54 +0,0 @@
import React from "react"
import PropTypes from "prop-types"
import { Map } from "immutable"
import SubtitlesButton from "../buttons/subtitles"
import TorrentsButton from "./torrents"
import ActionsButton from "./actions"
import ButtonToolbar from "react-bootstrap/ButtonToolbar"
const MovieButtons = (props) => (
<ButtonToolbar>
<ActionsButton
fetching={props.movie.get("fetchingDetails")}
movieId={props.movie.get("imdb_id")}
getDetails={props.getMovieDetails}
deleteMovie={props.deleteMovie}
hasMovie={(props.movie.get("polochon_url") !== "")}
wishlisted={props.movie.get("wishlisted")}
addToWishlist={props.addToWishlist}
deleteFromWishlist={props.deleteFromWishlist}
lastFetchUrl={props.lastFetchUrl}
/>
<TorrentsButton
movieTitle={props.movie.get("title")}
torrents={props.movie.get("torrents")}
addTorrent={props.addTorrent}
/>
{props.movie.get("polochon_url") !== "" &&
<SubtitlesButton
fetching={props.movie.get("fetchingSubtitles")}
subtitles={props.movie.get("subtitles")}
refreshSubtitles={props.refreshSubtitles}
resourceID={props.movie.get("imdb_id")}
type="movie"
/>
}
</ButtonToolbar>
);
MovieButtons.propTypes = {
movie: PropTypes.instanceOf(Map),
refreshSubtitles: PropTypes.func.isRequired,
addTorrent: PropTypes.func.isRequired,
getDetails: PropTypes.func,
deleteMovie: PropTypes.func.isRequired,
addToWishlist: PropTypes.func.isRequired,
deleteFromWishlist: PropTypes.func.isRequired,
getMovieDetails: PropTypes.func.isRequired,
lastFetchUrl: PropTypes.string,
}
export default MovieButtons;

View File

@ -2,16 +2,17 @@ import React from "react"
import PropTypes from "prop-types" import PropTypes from "prop-types"
import { OrderedMap, Map } from "immutable" import { OrderedMap, Map } from "immutable"
import { connect } from "react-redux" import { connect } from "react-redux"
import { addTorrent } from "../../actions/torrents" import { selectMovie, updateFilter } from "../../actions/movies"
import { refreshSubtitles } from "../../actions/subtitles"
import { addMovieToWishlist, deleteMovie, deleteMovieFromWishlist,
getMovieDetails, selectMovie, updateFilter } from "../../actions/movies"
import ListPosters from "../list/posters"
import ListDetails from "../list/details" import ListDetails from "../list/details"
import MovieButtons from "./buttons" import ListPosters from "../list/posters"
import Row from "react-bootstrap/Row" import { inLibrary } from "../../utils"
import { ShowMore } from "../buttons/showMore"
import { MovieSubtitlesButton } from "./subtitlesButton"
import { MovieTorrentsButton } from "./torrentsButton"
function mapStateToProps(state) { function mapStateToProps(state) {
return { return {
@ -19,25 +20,22 @@ function mapStateToProps(state) {
movies : state.movieStore.get("movies"), movies : state.movieStore.get("movies"),
filter : state.movieStore.get("filter"), filter : state.movieStore.get("filter"),
selectedImdbId : state.movieStore.get("selectedImdbId"), selectedImdbId : state.movieStore.get("selectedImdbId"),
lastFetchUrl : state.movieStore.get("lastFetchUrl"),
exploreOptions : state.movieStore.get("exploreOptions"), exploreOptions : state.movieStore.get("exploreOptions"),
}; };
} }
const mapDispatchToProps = { const mapDispatchToProps = {
selectMovie, getMovieDetails, addTorrent, selectMovie, updateFilter,
addMovieToWishlist, deleteMovie, deleteMovieFromWishlist,
refreshSubtitles, updateFilter,
}; };
const MovieList = (props) => { const MovieList = (props) => {
let selectedMovie = undefined; let selectedMovie = Map();
if (props.movies !== undefined && if (props.movies !== undefined &&
props.movies.has(props.selectedImdbId)) { props.movies.has(props.selectedImdbId)) {
selectedMovie = props.movies.get(props.selectedImdbId); selectedMovie = props.movies.get(props.selectedImdbId);
} }
return ( return (
<Row> <div className="row">
<ListPosters <ListPosters
data={props.movies} data={props.movies}
type="movies" type="movies"
@ -53,18 +51,25 @@ const MovieList = (props) => {
loading={props.loading} loading={props.loading}
/> />
<ListDetails data={selectedMovie} loading={props.loading}> <ListDetails data={selectedMovie} loading={props.loading}>
<MovieButtons <ShowMore
movie={selectedMovie} id={selectedMovie.get("imdb_id")}
getMovieDetails={props.getMovieDetails} inLibrary={inLibrary(selectedMovie)}
addTorrent={props.addTorrent} >
deleteMovie={props.deleteMovie} <MovieTorrentsButton
addToWishlist={props.addMovieToWishlist} torrents={selectedMovie.get("torrents")}
deleteFromWishlist={props.deleteMovieFromWishlist} imdbId={selectedMovie.get("imdb_id")}
lastFetchUrl={props.lastFetchUrl} title={selectedMovie.get("title")}
refreshSubtitles={props.refreshSubtitles} searching={selectedMovie.get("fetchingDetails", false)}
/> />
<MovieSubtitlesButton
subtitles={selectedMovie.get("subtitles")}
inLibrary={inLibrary(selectedMovie)}
imdbId={selectedMovie.get("imdb_id")}
searching={selectedMovie.get("fetchingSubtitles", false)}
/>
</ShowMore>
</ListDetails> </ListDetails>
</Row> </div>
); );
} }
MovieList.propTypes = { MovieList.propTypes = {
@ -73,15 +78,8 @@ MovieList.propTypes = {
selectedImdbId: PropTypes.string, selectedImdbId: PropTypes.string,
filter: PropTypes.string, filter: PropTypes.string,
loading: PropTypes.bool, loading: PropTypes.bool,
lastFetchUrl: PropTypes.string,
updateFilter: PropTypes.func, updateFilter: PropTypes.func,
selectMovie: PropTypes.func, selectMovie: PropTypes.func,
addMovieToWishlist: PropTypes.func,
deleteMovieFromWishlist: PropTypes.func,
deleteMovie: PropTypes.func,
addTorrent: PropTypes.func,
refreshSubtitles: PropTypes.func,
getMovieDetails: PropTypes.func,
match: PropTypes.object, match: PropTypes.object,
}; };

View File

@ -0,0 +1,33 @@
import React from "react"
import PropTypes from "prop-types"
import { List } from "immutable"
import { connect } from "react-redux"
import { searchMovieSubtitles } from "../../actions/subtitles"
import { SubtitlesButton } from "../buttons/subtitles"
const movieSubtitlesButton = ({
inLibrary,
imdbId,
searching,
searchMovieSubtitles,
subtitles,
}) => (
<SubtitlesButton
inLibrary={inLibrary}
searching={searching}
subtitles={subtitles}
search={() => searchMovieSubtitles(imdbId)}
/>
)
movieSubtitlesButton.propTypes = {
searching: PropTypes.bool,
inLibrary: PropTypes.bool,
imdbId: PropTypes.string,
searchMovieSubtitles: PropTypes.func,
subtitles: PropTypes.instanceOf(List),
}
export const MovieSubtitlesButton = connect(null, {searchMovieSubtitles})(movieSubtitlesButton);

View File

@ -1,92 +0,0 @@
import React from "react"
import PropTypes from "prop-types"
import { List } from "immutable"
import Dropdown from "react-bootstrap/Dropdown"
function buildMenuItems(torrents) {
if (!torrents || torrents.size === 0) {
return [];
}
const t = torrents.groupBy((el) => el.get("source"));
// Build the array of entries
let entries = [];
let dividerCount = t.size - 1;
for (let [source, torrentList] of t.entrySeq()) {
// Push the title
entries.push({
type: "header",
value: source,
});
// Push the torrents
for (let torrent of torrentList) {
entries.push({
type: "entry",
quality: torrent.get("quality"),
url: torrent.get("url"),
});
}
// Push the divider
if (dividerCount > 0) {
dividerCount--;
entries.push({ type: "divider" });
}
}
return entries;
}
const TorrentsButton = (props) => {
const entries = buildMenuItems(props.torrents);
const searchUrl = `#/torrents/search/movies/${encodeURI(props.movieTitle)}`;
return (
<Dropdown drop="up">
<Dropdown.Toggle variant="secondary" id="movie-torrents">
Torrents
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Header>
<span className="text-warning">Advanced</span>
</Dropdown.Header>
<Dropdown.Item href={searchUrl} >
<i className="fa fa-search" aria-hidden="true"></i> Search
</Dropdown.Item>
{entries.length > 0 &&
<Dropdown.Divider />
}
{entries.map((e, index) => {
switch (e.type) {
case "header":
return (
<Dropdown.Header key={index}>
<span className="text-warning">{e.value}</span>
</Dropdown.Header>
);
case "divider":
return (
<Dropdown.Divider key={index}/>
);
case "entry":
return (
<Dropdown.Item key={index} onClick={() => props.addTorrent(e.url)}>
{e.quality}
</Dropdown.Item>
);
}
})}
</Dropdown.Menu>
</Dropdown>
);
}
TorrentsButton.propTypes = {
torrents: PropTypes.instanceOf(List),
addTorrent: PropTypes.func.isRequired,
movieTitle: PropTypes.string.isRequired,
}
export default TorrentsButton;

View File

@ -0,0 +1,31 @@
import React from "react"
import PropTypes from "prop-types"
import { List } from "immutable"
import { connect } from "react-redux"
import { getMovieDetails } from "../../actions/movies"
import { TorrentsButton } from "../buttons/torrents"
const movieTorrentsButton = ({
torrents,
imdbId,
title,
searching,
getMovieDetails,
}) => (
<TorrentsButton
torrents={torrents}
searching={searching}
search={() => getMovieDetails(imdbId)}
url={`#/torrents/search/movies/${encodeURI(title)}`}
/>
)
movieTorrentsButton.propTypes = {
torrents: PropTypes.instanceOf(List),
imdbId: PropTypes.string,
title: PropTypes.string,
searching: PropTypes.bool,
getMovieDetails: PropTypes.func,
};
export const MovieTorrentsButton = connect(null, {getMovieDetails})(movieTorrentsButton);

View File

@ -1,369 +1,36 @@
import React, { useState } from "react" import React from "react"
import PropTypes from "prop-types" import PropTypes from "prop-types"
import { Map } from "immutable" import { Map } from "immutable"
import { connect } from "react-redux" import { connect } from "react-redux"
import { withRouter } from "react-router"
import { addTorrent } from "../../actions/torrents"
import { refreshSubtitles } from "../../actions/subtitles"
import { addShowToWishlist, deleteShowFromWishlist, getEpisodeDetails, fetchShowDetails } from "../../actions/shows"
import { DownloadAndStream } from "../buttons/download"
import { PolochonMetadata } from "../buttons/polochon"
import { ImdbBadge } from "../buttons/imdb"
import Loader from "../loader/loader" import Loader from "../loader/loader"
import SubtitlesButton from "../buttons/subtitles"
import RefreshIndicator from "../buttons/refresh"
import Tooltip from "react-bootstrap/Tooltip" import { Fanart } from "./details/fanart"
import OverlayTrigger from "react-bootstrap/OverlayTrigger" import { Header } from "./details/header"
import Dropdown from "react-bootstrap/Dropdown" import { SeasonsList } from "./details/seasons"
import SplitButton from "react-bootstrap/SplitButton"
// Helper to change 1 to 01 const mapStateToProps = (state) => ({
const pad = (d) => (d < 10) ? "0" + d.toString() : d.toString();
function mapStateToProps(state) {
return {
loading: state.showStore.get("loading"), loading: state.showStore.get("loading"),
show: state.showStore.get("show"), show: state.showStore.get("show"),
}; })
}
const mapDispatchToProps = {
addTorrent, addShowToWishlist, deleteShowFromWishlist,
fetchShowDetails, getEpisodeDetails, refreshSubtitles,
};
const ShowDetails = (props) => { const showDetails = ({ show, loading }) => {
if (props.loading) { if (loading === true) {
return (<Loader />); return (<Loader />);
} }
return ( return (
<React.Fragment> <React.Fragment>
<Fanart url={props.show.get("fanart_url")} /> <Fanart url={show.get("fanart_url")} />
<div className="row no-gutters"> <div className="row no-gutters">
<Header <Header data={show} />
data={props.show} <SeasonsList data={show} />
addToWishlist={props.addShowToWishlist}
deleteFromWishlist={props.deleteShowFromWishlist}
/>
<SeasonsList
data={props.show}
addTorrent={props.addTorrent}
addToWishlist={props.addShowToWishlist}
getEpisodeDetails={props.getEpisodeDetails}
refreshSubtitles={props.refreshSubtitles}
/>
</div> </div>
</React.Fragment> </React.Fragment>
); );
} }
ShowDetails.propTypes = { showDetails.propTypes = {
loading: PropTypes.bool, loading: PropTypes.bool,
show: PropTypes.instanceOf(Map), show: PropTypes.instanceOf(Map),
deleteShowFromWishlist: PropTypes.func,
addShowToWishlist: PropTypes.func,
addTorrent: PropTypes.func,
refreshSubtitles: PropTypes.func,
getEpisodeDetails: PropTypes.func,
}; };
export default connect(mapStateToProps, mapDispatchToProps)(ShowDetails); export const ShowDetails = connect(mapStateToProps)(showDetails);
const Fanart = (props) => (
<div className="show-fanart mx-n3 mt-n1">
<img
className="img w-100 object-fit-cover object-position-top mh-40vh"
src={props.url}
/>
</div>
)
Fanart.propTypes = {
url: PropTypes.string,
}
const Header = (props) => (
<div className="card col-12 col-md-10 offset-md-1 mt-n3 mb-3">
<div className="d-flex flex-column flex-md-row">
<div className="d-flex justify-content-center">
<img
className="overflow-hidden object-fit-cover"
src={props.data.get("poster_url")}
/>
</div>
<div>
<div className="card-body">
<h5 className="card-title">{props.data.get("title")}</h5>
<p className="card-text">{props.data.get("year")}</p>
<p className="card-text">{props.data.get("rating")}</p>
<div className="my-1">
<ImdbBadge imdbId={props.data.get("imdb_id")} />
</div>
<p className="card-text plot">{props.data.get("plot")}</p>
<TrackHeader
data={props.data}
addToWishlist={props.addToWishlist}
deleteFromWishlist={props.deleteFromWishlist}
/>
</div>
</div>
</div>
</div>
);
Header.propTypes = {
data: PropTypes.instanceOf(Map),
deleteFromWishlist: PropTypes.func,
addToWishlist: PropTypes.func,
};
const SeasonsList = (props) => (
<div className="col col-12 col-md-10 offset-md-1">
{props.data.get("seasons").entrySeq().map(function([season, data]) {
return (
<Season
key={`season-list-key-${season}`}
data={data}
season={season}
showName={props.data.get("title")}
addTorrent={props.addTorrent}
addToWishlist={props.addToWishlist}
getEpisodeDetails={props.getEpisodeDetails}
refreshSubtitles={props.refreshSubtitles}
/>
);
})}
</div>
);
SeasonsList.propTypes = {
data: PropTypes.instanceOf(Map),
addToWishlist: PropTypes.func,
addTorrent: PropTypes.func,
refreshSubtitles: PropTypes.func,
getEpisodeDetails: PropTypes.func,
};
const Season = (props) => {
const [show, setShow] = useState(false);
const visibility = show ? "d-flex flex-column" : "d-none";
const icon = show ? "down" : "left"
return (
<div className="card mb-3">
<div className="card-header clickable" onClick={() => setShow(!show)}>
<h5 className="m-0">
Season {props.season}
<small className="text-primary"> ({props.data.toList().size} episodes)</small>
<i className={`float-right fa fa-chevron-${icon}`}></i>
</h5>
</div>
<div className={`card-body ${visibility}`}>
{props.data.toList().map(function(episode) {
let key = `${episode.get("season")}-${episode.get("episode")}`;
return (
<Episode
key={key}
data={episode}
showName={props.showName}
addTorrent={props.addTorrent}
addToWishlist={props.addToWishlist}
getEpisodeDetails={props.getEpisodeDetails}
refreshSubtitles={props.refreshSubtitles}
/>
)
})}
</div>
</div>
)
}
Season.propTypes = {
data: PropTypes.instanceOf(Map),
season: PropTypes.number,
showName: PropTypes.string,
addToWishlist: PropTypes.func,
addTorrent: PropTypes.func,
refreshSubtitles: PropTypes.func,
getEpisodeDetails: PropTypes.func,
};
const Episode = (props) => (
<div className="d-flex flex-wrap flex-md-nowrap align-items-center">
<TrackButton data={props.data} addToWishlist={props.addToWishlist} />
<span className="mx-2 text">{props.data.get("episode")}</span>
<span className="mx-2 text text-truncate flex-fill">{props.data.get("title")}</span>
<span>
<PolochonMetadata
quality={props.data.get("quality")}
releaseGroup={props.data.get("release_group")}
container={props.data.get("container")}
audioCodec={props.data.get("audio_codec")}
videoCodec={props.data.get("video_codec")}
light
/>
<DownloadAndStream
name={`${props.showName} - S${pad(props.data.get("season"))}E${pad(props.data.get("episode"))}`}
url={props.data.get("polochon_url")}
subtitles={props.data.get("subtitles")}
/>
</span>
<div className="align-self-end btn-toolbar">
{props.data.get("polochon_url") !== "" &&
<SubtitlesButton
fetching={props.data.get("fetchingSubtitles")}
subtitles={props.data.get("subtitles")}
refreshSubtitles={props.refreshSubtitles}
resourceID={props.data.get("show_imdb_id")}
season={props.data.get("season")}
episode={props.data.get("episode")}
type="episode"
xs
/>
}
{props.data.get("torrents") && props.data.get("torrents").toList().map(function(torrent) {
let key = `${props.data.get("season")}-${props.data.get("episode")}-${torrent.get("source")}-${torrent.get("quality")}`;
return (
<Torrent
data={torrent}
key={key}
addTorrent={props.addTorrent}
/>
)
})}
<GetDetailsButtonWithRouter
showName={props.showName}
data={props.data}
getEpisodeDetails={props.getEpisodeDetails}
/>
</div>
</div>
)
Episode.propTypes = {
data: PropTypes.instanceOf(Map),
season: PropTypes.number,
showName: PropTypes.string,
addToWishlist: PropTypes.func,
addTorrent: PropTypes.func,
refreshSubtitles: PropTypes.func,
getEpisodeDetails: PropTypes.func,
};
const Torrent = (props) => (
<button type="button"
className="btn btn-primary btn-sm"
onClick={() => props.addTorrent(props.data.get("url"))}
href={props.data.url} >
<i className="fa fa-download"></i> {props.data.get("quality")}
</button>
)
Torrent.propTypes = {
data: PropTypes.instanceOf(Map),
addTorrent: PropTypes.func,
};
const TrackHeader = (props) => {
const trackedSeason = props.data.get("tracked_season");
const trackedEpisode = props.data.get("tracked_episode");
const imdbId = props.data.get("imdb_id");
const wishlisted = (trackedSeason !== null && trackedEpisode !== null);
const handleClick = () => {
if (wishlisted) {
props.deleteFromWishlist(imdbId);
} else {
props.addToWishlist(imdbId);
}
}
if (wishlisted) {
const msg = (trackedSeason !== 0 && trackedEpisode !== 0)
? (<p>Show tracked from <strong>season {trackedSeason} episode {trackedEpisode}</strong></p>)
: (<p>Whole show tracked</p>);
return (
<span className="card-text">
{msg}
<a className="btn btn-sm btn-danger" onClick={handleClick}>
<i className="fa fa-bookmark"></i> Untrack the show
</a>
</span>
);
}
return (
<span className="card-text">
<p>Tracking inactive</p>
<a className="btn btn-sm btn-info" onClick={(e) => handleClick(e)}>
<i className="fa fa-bookmark-o"></i> Track the whole show
</a>
</span>
);
}
TrackHeader.propTypes = {
data: PropTypes.instanceOf(Map),
addToWishlist: PropTypes.func,
deleteFromWishlist: PropTypes.func,
};
const TrackButton = (props) => {
const imdbId = props.data.get("show_imdb_id");
const season = props.data.get("season");
const episode = props.data.get("episode");
const tooltipId = `tooltip-${props.data.season}-${props.data.episode}`;
const tooltip = (
<Tooltip id={tooltipId}>Track show from here</Tooltip>
);
return (
<OverlayTrigger placement="top" overlay={tooltip}>
<span className="btn clickable"
onClick={() => props.addToWishlist(imdbId, season, episode)}>
<i className="fa fa-bookmark"></i>
</span>
</OverlayTrigger>
);
}
TrackButton.propTypes = {
data: PropTypes.instanceOf(Map),
addToWishlist: PropTypes.func,
};
const GetDetailsButton = (props) => {
const imdbId = props.data.get("show_imdb_id");
const season = props.data.get("season");
const episode = props.data.get("episode");
const id = `${imdbId}-${season}-${episode}-refresh-dropdown`;
const handleFetchClick = () => {
if (props.data.get("fetching")) { return }
props.getEpisodeDetails(imdbId, season, episode);
}
const handleAdvanceTorrentSearchClick = () => {
const search = `${props.showName} S${pad(season)}E${pad(episode)}`;
const url = `/torrents/search/shows/${encodeURI(search)}`;
props.history.push(url);
}
return (
<SplitButton
drop="up"
variant="info"
title={<RefreshIndicator refresh={props.data.get("fetching")} />}
size="sm"
id={`refresh-${id}`}
onClick={handleFetchClick}>
<Dropdown.Item onClick={handleAdvanceTorrentSearchClick}>
<span>
<i className="fa fa-magnet"></i> Advanced torrent search
</span>
</Dropdown.Item>
</SplitButton>
);
}
GetDetailsButton.propTypes = {
data: PropTypes.instanceOf(Map).isRequired,
history: PropTypes.object.isRequired,
showName: PropTypes.string.isRequired,
getEpisodeDetails: PropTypes.func.isRequired,
};
const GetDetailsButtonWithRouter = withRouter(GetDetailsButton);

View File

@ -0,0 +1,71 @@
import React from "react"
import PropTypes from "prop-types"
import { Map } from "immutable"
import { inLibrary, prettyEpisodeName } from "../../../utils"
import { Plot } from "../../details/plot"
import { PolochonMetadata } from "../../details/polochon"
import { ReleaseDate } from "../../details/releaseDate"
import { Runtime } from "../../details/runtime"
import { Title } from "../../details/title"
import { DownloadAndStream } from "../../buttons/download"
import { ShowMore } from "../../buttons/showMore"
import { EpisodeSubtitlesButton } from "./subtitlesButton"
import { EpisodeThumb } from "./episodeThumb"
import { EpisodeTorrentsButton } from "./torrentsButton"
export const Episode = (props) => (
<div className="d-flex flex-column flex-lg-row mb-3 pb-3 border-bottom border-light">
<EpisodeThumb url={props.data.get("thumb")} />
<div className="d-flex flex-column">
<Title
title={`${props.data.get("episode")}. ${props.data.get("title")}`}
xs
/>
<ReleaseDate date={props.data.get("aired")} />
<Runtime runtime={props.data.get("runtime")} />
<Plot plot={props.data.get("plot")} />
<DownloadAndStream
name={prettyEpisodeName(props.showName, props.data.get("season"), props.data.get("episode"))}
url={props.data.get("polochon_url")}
subtitles={props.data.get("subtitles")}
/>
<PolochonMetadata
quality={props.data.get("quality")}
releaseGroup={props.data.get("release_group")}
container={props.data.get("container")}
audioCodec={props.data.get("audio_codec")}
videoCodec={props.data.get("video_codec")}
light
/>
<ShowMore
id={prettyEpisodeName(props.showName, props.data.get("season"), props.data.get("episode"))}
inLibrary={inLibrary(props.data)}
>
<EpisodeTorrentsButton
torrents={props.data.get("torrents")}
showName={props.showName}
imdbId={props.data.get("show_imdb_id")}
season={props.data.get("season")}
episode={props.data.get("episode")}
searching={props.data.get("fetching")}
/>
<EpisodeSubtitlesButton
subtitles={props.data.get("subtitles")}
inLibrary={inLibrary(props.data)}
searching={props.data.get("fetchingSubtitles", false)}
imdbId={props.data.get("show_imdb_id")}
season={props.data.get("season")}
episode={props.data.get("episode")}
/>
</ShowMore>
</div>
</div>
)
Episode.propTypes = {
data: PropTypes.instanceOf(Map).isRequired,
showName: PropTypes.string.isRequired,
};

View File

@ -0,0 +1,13 @@
import React from "react"
import PropTypes from "prop-types"
export const EpisodeThumb = ({ url }) => {
if (url === "") { return null }
return (
<div className="mr-0 mr-lg-2 mb-2 mb-lg-0 episode-thumb">
<img src={url} />
</div>
)
}
EpisodeThumb.propTypes = { url: PropTypes.string };
EpisodeThumb.defaultProps = { url: "" };

View File

@ -0,0 +1,14 @@
import React from "react"
import PropTypes from "prop-types"
export const Fanart = ({ url }) => (
<div className="show-fanart mx-n3 mt-n1">
<img
className="img w-100 object-fit-cover object-position-top mh-40vh"
src={url}
/>
</div>
)
Fanart.propTypes = {
url: PropTypes.string,
}

View File

@ -0,0 +1,51 @@
import React from "react"
import PropTypes from "prop-types"
import { Map } from "immutable"
import { TrackHeader } from "./track"
import { ImdbBadge } from "../../buttons/imdb"
import { Plot } from "../../details/plot"
import { Rating } from "../../details/rating"
import { ReleaseDate } from "../../details/releaseDate"
export const Header = (props) => (
<div className="card col-12 col-md-10 offset-md-1 mt-n3 mb-3">
<div className="d-flex flex-column flex-md-row">
<div className="d-flex justify-content-center">
<img
className="overflow-hidden object-fit-cover"
src={props.data.get("poster_url")}
/>
</div>
<div>
<div className="card-body">
<h5 className="card-title">{props.data.get("title")}</h5>
<p className="card-text">
<ReleaseDate date={props.data.get("year")} />
</p>
<p className="card-text">
<Rating rating={props.data.get("rating")} />
</p>
<span className="card-text">
<ImdbBadge imdbId={props.data.get("imdb_id")} />
</span>
<p className="card-text">
<Plot plot={props.data.get("plot")} />
</p>
<span className="card-text">
<TrackHeader
data={props.data}
addToWishlist={props.addToWishlist}
deleteFromWishlist={props.deleteFromWishlist}
/>
</span>
</div>
</div>
</div>
</div>
);
Header.propTypes = {
data: PropTypes.instanceOf(Map),
deleteFromWishlist: PropTypes.func,
addToWishlist: PropTypes.func,
};

View File

@ -0,0 +1,48 @@
import React, { useState } from "react"
import PropTypes from "prop-types"
import { Map } from "immutable"
import { Episode } from "./episode"
export const Season = (props) => {
const [show, setShow] = useState(false);
const visibility = show ? "d-flex flex-column" : "d-none";
const icon = show ? "down" : "left"
return (
<div className="card mb-3">
<div className="card-header clickable" onClick={() => setShow(!show)}>
<h5 className="m-0">
Season {props.season}
<small className="text-primary"> ({props.data.toList().size} episodes)</small>
<i className={`float-right fa fa-chevron-${icon}`}></i>
</h5>
</div>
<div className={`card-body ${visibility}`}>
{props.data.toList().map(function(episode) {
let key = `${episode.get("season")}-${episode.get("episode")}`;
return (
<Episode
key={key}
data={episode}
showName={props.showName}
addTorrent={props.addTorrent}
addToWishlist={props.addToWishlist}
getEpisodeDetails={props.getEpisodeDetails}
refreshSubtitles={props.refreshSubtitles}
/>
)
})}
</div>
</div>
)
}
Season.propTypes = {
data: PropTypes.instanceOf(Map),
season: PropTypes.number,
showName: PropTypes.string,
addToWishlist: PropTypes.func,
addTorrent: PropTypes.func,
refreshSubtitles: PropTypes.func,
getEpisodeDetails: PropTypes.func,
};

View File

@ -0,0 +1,31 @@
import React from "react"
import PropTypes from "prop-types"
import { Map } from "immutable"
import { Season } from "./season"
export const SeasonsList = (props) => (
<div className="col col-12 col-md-10 offset-md-1">
{props.data.get("seasons").entrySeq().map(function([season, data]) {
return (
<Season
key={`season-list-key-${season}`}
data={data}
season={season}
showName={props.data.get("title")}
addTorrent={props.addTorrent}
addToWishlist={props.addToWishlist}
getEpisodeDetails={props.getEpisodeDetails}
refreshSubtitles={props.refreshSubtitles}
/>
);
})}
</div>
);
SeasonsList.propTypes = {
data: PropTypes.instanceOf(Map),
addToWishlist: PropTypes.func,
addTorrent: PropTypes.func,
refreshSubtitles: PropTypes.func,
getEpisodeDetails: PropTypes.func,
};

View File

@ -0,0 +1,37 @@
import React from "react"
import PropTypes from "prop-types"
import { List } from "immutable"
import { connect } from "react-redux"
import { searchEpisodeSubtitles } from "../../../actions/subtitles"
import { SubtitlesButton } from "../../buttons/subtitles"
const episodeSubtitlesButton = ({
inLibrary,
imdbId,
season,
episode,
searching,
searchEpisodeSubtitles,
subtitles,
}) => (
<SubtitlesButton
subtitles={subtitles}
inLibrary={inLibrary}
searching={searching}
search={() => searchEpisodeSubtitles(imdbId, season, episode)}
/>
)
episodeSubtitlesButton.propTypes = {
inLibrary: PropTypes.bool,
searching: PropTypes.bool,
imdbId: PropTypes.string,
season: PropTypes.number,
episode: PropTypes.number,
searchEpisodeSubtitles: PropTypes.func,
subtitles: PropTypes.instanceOf(List),
}
export const EpisodeSubtitlesButton = connect(null, {searchEpisodeSubtitles})(episodeSubtitlesButton);

View File

@ -0,0 +1,38 @@
import React from "react"
import PropTypes from "prop-types"
import { connect } from "react-redux"
import { List } from "immutable"
import { getEpisodeDetails } from "../../../actions/shows"
import { TorrentsButton } from "../../buttons/torrents"
import { prettyEpisodeName } from "../../../utils"
const episodeTorrentsButton = ({
torrents,
imdbId,
season,
episode,
showName,
searching,
getEpisodeDetails,
}) => (
<TorrentsButton
torrents={torrents}
searching={searching}
search={() => getEpisodeDetails(imdbId, season, episode)}
url={`#/torrents/search/shows/${
encodeURI(prettyEpisodeName(showName, season, episode))
}`}
/>
)
episodeTorrentsButton.propTypes = {
torrents: PropTypes.instanceOf(List),
showName: PropTypes.string.isRequired,
imdbId: PropTypes.string.isRequired,
episode: PropTypes.number.isRequired,
season: PropTypes.number.isRequired,
searching: PropTypes.bool.isRequired,
getEpisodeDetails: PropTypes.func.isRequired,
};
export const EpisodeTorrentsButton = connect(null, {getEpisodeDetails})(episodeTorrentsButton);

View File

@ -0,0 +1,82 @@
import React from "react"
import PropTypes from "prop-types"
import { Map } from "immutable"
import { connect } from "react-redux"
import { addShowToWishlist, deleteShowFromWishlist } from "../../../actions/shows"
import Tooltip from "react-bootstrap/Tooltip"
import OverlayTrigger from "react-bootstrap/OverlayTrigger"
export const trackHeader = (props) => {
const trackedSeason = props.data.get("tracked_season");
const trackedEpisode = props.data.get("tracked_episode");
const imdbId = props.data.get("imdb_id");
const wishlisted = (trackedSeason !== null && trackedEpisode !== null);
const handleClick = () => {
if (wishlisted) {
props.deleteShowFromWishlist(imdbId);
} else {
props.addShowToWishlist(imdbId);
}
}
if (wishlisted) {
const msg = (trackedSeason !== 0 && trackedEpisode !== 0)
? (<p>Show tracked from <strong>season {trackedSeason} episode {trackedEpisode}</strong></p>)
: (<p>Whole show tracked</p>);
return (
<span className="card-text">
{msg}
<a className="btn btn-sm btn-danger" onClick={handleClick}>
<i className="fa fa-bookmark"></i> Untrack the show
</a>
</span>
);
}
return (
<span className="card-text">
<p>Tracking inactive</p>
<a className="btn btn-sm btn-info" onClick={(e) => handleClick(e)}>
<i className="fa fa-bookmark-o"></i> Track the whole show
</a>
</span>
);
}
trackHeader.propTypes = {
data: PropTypes.instanceOf(Map),
addShowToWishlist: PropTypes.func,
deleteShowFromWishlist: PropTypes.func,
};
export const TrackHeader = connect(null, {
addShowToWishlist,
deleteShowFromWishlist,
})(trackHeader);
export const trackButton = (props) => {
const imdbId = props.data.get("show_imdb_id");
const season = props.data.get("season");
const episode = props.data.get("episode");
const tooltipId = `tooltip-${props.data.season}-${props.data.episode}`;
const tooltip = (
<Tooltip id={tooltipId}>Track show from here</Tooltip>
);
return (
<OverlayTrigger placement="top" overlay={tooltip}>
<span className="btn clickable"
onClick={() => props.addShowToWishlist(imdbId, season, episode)}>
<i className="fa fa-bookmark"></i>
</span>
</OverlayTrigger>
);
}
trackButton.propTypes = {
data: PropTypes.instanceOf(Map),
addShowToWishlist: PropTypes.func,
};
export const TrackButton = connect(null, {addShowToWishlist})(trackButton);

View File

@ -7,7 +7,6 @@ import { selectShow, addShowToWishlist,
import ListDetails from "../list/details" import ListDetails from "../list/details"
import ListPosters from "../list/posters" import ListPosters from "../list/posters"
import ShowButtons from "./listButtons"
function mapStateToProps(state) { function mapStateToProps(state) {
return { return {
@ -28,7 +27,7 @@ const ShowList = (props) => {
props.history.push("/shows/details/" + imdbId); props.history.push("/shows/details/" + imdbId);
} }
let selectedShow; let selectedShow = Map();
if (props.selectedImdbId !== "") { if (props.selectedImdbId !== "") {
selectedShow = props.shows.get(props.selectedImdbId); selectedShow = props.shows.get(props.selectedImdbId);
} }
@ -49,17 +48,7 @@ const ShowList = (props) => {
params={props.match.params} params={props.match.params}
loading={props.loading} loading={props.loading}
/> />
{selectedShow && <ListDetails data={selectedShow} loading={props.loading} />
<ListDetails data={selectedShow} loading={props.loading}>
<ShowButtons
show={selectedShow}
deleteFromWishlist={props.deleteShowFromWishlist}
addToWishlist={props.addShowToWishlist}
getDetails={props.getShowDetails}
updateFilter={props.updateFilter}
/>
</ListDetails>
}
</div> </div>
); );
} }

View File

@ -1,62 +0,0 @@
import React from "react"
import PropTypes from "prop-types"
import { Map } from "immutable"
import { Link } from "react-router-dom"
import Dropdown from "react-bootstrap/Dropdown"
import ButtonToolbar from "react-bootstrap/ButtonToolbar"
import { WishlistButton, RefreshButton } from "../buttons/actions"
const ShowButtons = (props) => (
<ButtonToolbar>
<ActionsButton
show={props.show}
addToWishlist={props.addToWishlist}
deleteFromWishlist={props.deleteFromWishlist}
getDetails={props.getDetails}
/>
<Link className="btn btn-primary" to={"/shows/details/" + props.show.get("imdb_id")}>
<i className="fa fa-external-link"></i> Details
</Link>
</ButtonToolbar>
);
ShowButtons.propTypes = {
show: PropTypes.instanceOf(Map),
addToWishlist: PropTypes.func.isRequired,
deleteFromWishlist: PropTypes.func.isRequired,
getDetails: PropTypes.func.isRequired,
}
const ActionsButton = (props) => {
let wishlisted = (props.show.get("tracked_season") !== null && props.show.get("tracked_episode") !== null);
return (
<Dropdown drop="up">
<Dropdown.Toggle variant="secondary" id="movie-button-actions">
Actions
</Dropdown.Toggle>
<Dropdown.Menu>
<RefreshButton
fetching={props.show.get("fetchingDetails")}
resourceId={props.show.get("imdb_id")}
getDetails={props.getDetails}
/>
<WishlistButton
resourceId={props.show.get("imdb_id")}
wishlisted={wishlisted}
addToWishlist={props.addToWishlist}
deleteFromWishlist={props.deleteFromWishlist}
/>
</Dropdown.Menu>
</Dropdown>
);
}
ActionsButton.propTypes = {
show: PropTypes.instanceOf(Map),
addToWishlist: PropTypes.func.isRequired,
deleteFromWishlist: PropTypes.func.isRequired,
getDetails: PropTypes.func.isRequired,
}
export default ShowButtons;

19
frontend/js/utils.js Normal file
View File

@ -0,0 +1,19 @@
export const prettyDurationFromMinutes = (runtime) => {
const hours = Math.floor(runtime / 60);
const minutes = (runtime % 60);
let duration = "";
if (hours > 0) { duration += hours + "h" }
if (minutes > 0) { duration += ("0" + minutes).slice(-2) }
if (hours === 0) { duration += " min" }
return duration;
}
const pad = (d) => (d < 10) ? "0" + d.toString() : d.toString();
export const prettyEpisodeName = (showName, season, episode) =>
`${showName} S${pad(season)}E${pad(episode)}`;
export const inLibrary = (element) =>
element.get("polochon_url", "") !== "";

View File

@ -76,18 +76,25 @@ div.show.dropdown.nav-item > div {
} }
.video-details { .video-details {
font-size: $font-size-sm; > div, > p, > span {
div, p { margin-bottom: 1rem;
margin-bottom: .3rem; @media (max-width: 330px) {
margin-bottom: 0rem;
}
}
}
.episode-thumb {
img {
@include media-breakpoint-down(md) {
width: 100%;
margin-bottom: 1rem;
} }
} display: block;
@include media-breakpoint-up(sm) { height: auto;
.video-details { max-width:40rem;
font-size: $font-size-base; max-height:22.5rem;
div, p {
margin-bottom: $paragraph-margin-bottom;
}
} }
} }