Update redux state management #21

Merged
PouuleT merged 1 commits from immer into master 2020-04-07 16:48:16 +00:00
66 changed files with 1330 additions and 1678 deletions

View File

@ -20,6 +20,7 @@
} }
}, },
"env": { "env": {
"es6": true,
"browser": true, "browser": true,
"mocha": true, "mocha": true,
"node": true "node": true

View File

@ -106,15 +106,6 @@ export function selectShow(imdbId) {
}; };
} }
export function updateFilter(filter) {
return {
type: "SHOWS_UPDATE_FILTER",
payload: {
filter,
},
};
}
export function updateLastShowsFetchUrl(url) { export function updateLastShowsFetchUrl(url) {
return { return {
type: "UPDATE_LAST_SHOWS_FETCH_URL", type: "UPDATE_LAST_SHOWS_FETCH_URL",

View File

@ -26,25 +26,25 @@ import store, { history } from "./store";
// Components // Components
import { AdminPanel } from "./components/admins/panel"; import { AdminPanel } from "./components/admins/panel";
import { Notifications } from "./components/notifications/notifications"; import { Notifications } from "./components/notifications/notifications";
import Alert from "./components/alerts/alert"; import { Alert } from "./components/alerts/alert";
import MovieList from "./components/movies/list"; import MovieList from "./components/movies/list";
import NavBar from "./components/navbar"; import { AppNavBar } 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 TorrentList from "./components/torrents/list"; import { TorrentList } from "./components/torrents/list";
import TorrentSearch from "./components/torrents/search"; import { TorrentSearch } from "./components/torrents/search";
import UserActivation from "./components/users/activation"; import { UserActivation } from "./components/users/activation";
import UserLoginForm from "./components/users/login"; import { UserLoginForm } from "./components/users/login";
import UserLogout from "./components/users/logout"; import { UserLogout } from "./components/users/logout";
import UserProfile from "./components/users/profile"; import { UserProfile } from "./components/users/profile";
import UserSignUp from "./components/users/signup"; import { UserSignUp } from "./components/users/signup";
import UserTokens from "./components/users/tokens"; import { UserTokens } from "./components/users/tokens";
const App = () => ( const App = () => (
<div> <div>
<WsHandler /> <WsHandler />
<NavBar /> <AppNavBar />
<Alert /> <Alert />
<Notifications /> <Notifications />
<Container fluid> <Container fluid>

View File

@ -8,11 +8,9 @@ import { setUserToken } from "./actions/users";
export const ProtectedRoute = ({ component: Component, ...otherProps }) => { export const ProtectedRoute = ({ component: Component, ...otherProps }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const isLogged = useSelector((state) => state.userStore.get("isLogged")); const isLogged = useSelector((state) => state.user.isLogged);
const isActivated = useSelector((state) => const isActivated = useSelector((state) => state.user.isActivated);
state.userStore.get("isActivated") const isTokenSet = useSelector((state) => state.user.isTokenSet);
);
const isTokenSet = useSelector((state) => state.userStore.get("isTokenSet"));
const isAuthenticated = () => { const isAuthenticated = () => {
if (isTokenSet) { if (isTokenSet) {
@ -52,7 +50,7 @@ ProtectedRoute.propTypes = {
}; };
export const AdminRoute = ({ component: Component, ...otherProps }) => { export const AdminRoute = ({ component: Component, ...otherProps }) => {
const isAdmin = useSelector((state) => state.userStore.get("isAdmin")); const isAdmin = useSelector((state) => state.user.isAdmin);
return ( return (
<Route <Route
{...otherProps} {...otherProps}

View File

@ -7,10 +7,8 @@ import Modules from "../modules/modules";
export const AdminModules = () => { export const AdminModules = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const loading = useSelector((state) => const loading = useSelector((state) => state.admin.fetchingModules);
state.adminStore.get("fetchingModules") const modules = useSelector((state) => state.admin.modules);
);
const modules = useSelector((state) => state.adminStore.get("modules"));
useEffect(() => { useEffect(() => {
dispatch(getAdminModules()); dispatch(getAdminModules());

View File

@ -25,8 +25,14 @@ export const Stat = ({ name, count, torrentCount, torrentCountById }) => (
</div> </div>
); );
Stat.propTypes = { Stat.propTypes = {
name: PropTypes.string, name: PropTypes.string.isRequired,
count: PropTypes.number, count: PropTypes.number.isRequired,
torrentCount: PropTypes.number, torrentCount: PropTypes.number.isRequired,
torrentCountById: PropTypes.number, torrentCountById: PropTypes.number.isRequired,
};
Stat.defaultProps = {
name: "",
count: 0,
torrentCount: 0,
torrentCountById: 0,
}; };

View File

@ -7,7 +7,7 @@ import { getStats } from "../../actions/admins";
export const Stats = () => { export const Stats = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const stats = useSelector((state) => state.adminStore.get("stats")); const stats = useSelector((state) => state.admin.stats);
useEffect(() => { useEffect(() => {
dispatch(getStats()); dispatch(getStats());
@ -17,21 +17,21 @@ export const Stats = () => {
<div className="row d-flex flex-wrap"> <div className="row d-flex flex-wrap">
<Stat <Stat
name="Movies" name="Movies"
count={stats.get("movies_count")} count={stats["movies_count"]}
torrentCount={stats.get("movies_torrents_count")} torrentCount={stats["movies_torrents_count"]}
torrentCountById={stats.get("movies_torrents_count_by_id")} torrentCountById={stats["movies_torrents_count_by_id"]}
/> />
<Stat <Stat
name="Shows" name="Shows"
count={stats.get("shows_count")} count={stats["shows_count"]}
torrentCount={stats.get("episodes_torrents_count")} torrentCount={stats["episodes_torrents_count"]}
torrentCountById={stats.get("shows_torrents_count_by_id")} torrentCountById={stats["shows_torrents_count_by_id"]}
/> />
<Stat <Stat
name="Episodes" name="Episodes"
count={stats.get("episodes_count")} count={stats["episodes_count"]}
torrentCount={stats.get("episodes_torrents_count")} torrentCount={stats["episodes_torrents_count"]}
torrentCountById={stats.get("episodes_torrents_count_by_id")} torrentCountById={stats["episodes_torrents_count_by_id"]}
/> />
</div> </div>
); );

View File

@ -15,7 +15,12 @@ export const TorrentsStat = ({ count, torrentCount, torrentCountById }) => {
); );
}; };
TorrentsStat.propTypes = { TorrentsStat.propTypes = {
count: PropTypes.number, count: PropTypes.number.isRequired,
torrentCount: PropTypes.number, torrentCount: PropTypes.number.isRequired,
torrentCountById: PropTypes.number, torrentCountById: PropTypes.number.isRequired,
};
TorrentsStat.defaultProps = {
count: 1,
torrentCount: 0,
torrentCountById: 0,
}; };

View File

@ -1,65 +1,43 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { useSelector } from "react-redux";
import { UserEdit } from "./userEdit"; import { UserEdit } from "./userEdit";
export const User = ({ export const User = ({ id }) => {
id, const user = useSelector((state) => state.admin.users.get(id));
admin, const polochon = user.polochon;
activated,
name,
polochonActivated,
polochonUrl,
polochonName,
polochonId,
token,
}) => {
return ( return (
<tr> <tr>
<td>{id}</td> <td>{user.id}</td>
<td>{name}</td> <td>{user.name}</td>
<td> <td>
<span <span
className={activated ? "fa fa-check" : "fa fa-times text-danger"} className={user.activated ? "fa fa-check" : "fa fa-times text-danger"}
></span> ></span>
</td> </td>
<td> <td>
<span className={admin ? "fa fa-check" : "fa fa-times"}></span> <span className={user.admin ? "fa fa-check" : "fa fa-times"}></span>
</td> </td>
<td> <td>
{polochonName !== "" ? polochonName : "-"} {polochon ? polochon.name : "-"}
{polochonUrl !== "" && <small className="ml-1">({polochonUrl})</small>} {polochon && <small className="ml-1">({polochon.url})</small>}
</td> </td>
<td>{token}</td> <td>{user.token}</td>
<td> <td>
<span <span
className={ className={
polochonActivated ? "fa fa-check" : "fa fa-times text-danger" user.polochon_activated ? "fa fa-check" : "fa fa-times text-danger"
} }
></span> ></span>
</td> </td>
<td> <td>
<UserEdit <UserEdit id={id} />
id={id}
name={name}
admin={admin}
activated={activated}
polochonActivated={polochonActivated}
polochonToken={token}
polochonId={polochonId}
/>
</td> </td>
</tr> </tr>
); );
}; };
User.propTypes = { User.propTypes = {
id: PropTypes.string, id: PropTypes.string.isRequired,
name: PropTypes.string,
polochonId: PropTypes.string,
polochonUrl: PropTypes.string,
polochonName: PropTypes.string,
token: PropTypes.string,
admin: PropTypes.bool,
activated: PropTypes.bool,
polochonActivated: PropTypes.bool,
}; };

View File

@ -10,25 +10,20 @@ import { PolochonSelect } from "../polochons/select";
import { FormModal } from "../forms/modal"; import { FormModal } from "../forms/modal";
import { FormInput } from "../forms/input"; import { FormInput } from "../forms/input";
export const UserEdit = ({ export const UserEdit = ({ id }) => {
id,
name,
admin: initAdmin,
activated: initActivated,
polochonToken,
polochonId: initPolochonId,
polochonActivated: initPolochonActivated,
}) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const publicPolochons = useSelector((state) => state.polochon.get("public")); const user = useSelector((state) => state.admin.users.get(id));
const polochon = user.polochon;
const [modal, setModal] = useState(false); const [modal, setModal] = useState(false);
const [admin, setAdmin] = useState(initAdmin); const [admin, setAdmin] = useState(user.admin);
const [activated, setActivated] = useState(initActivated); const [activated, setActivated] = useState(user.activated);
const [token, setToken] = useState(polochonToken); const [token, setToken] = useState(user.token);
const [polochonId, setPolochonId] = useState(initPolochonId); const [polochonId, setPolochonId] = useState(
polochon ? polochon.id : undefined
);
const [polochonActivated, setPolochonActivated] = useState( const [polochonActivated, setPolochonActivated] = useState(
initPolochonActivated user.polochon_activated
); );
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
@ -106,11 +101,7 @@ export const UserEdit = ({
updateValue={setPassword} updateValue={setPassword}
/> />
<PolochonSelect <PolochonSelect value={polochonId} changeValue={setPolochonId} />
value={polochonId}
changeValue={setPolochonId}
polochonList={publicPolochons}
/>
<FormInput <FormInput
label="Polochon Token" label="Polochon Token"
value={token} value={token}
@ -139,11 +130,5 @@ export const UserEdit = ({
); );
}; };
UserEdit.propTypes = { UserEdit.propTypes = {
id: PropTypes.string, id: PropTypes.string.isRequired,
name: PropTypes.string,
activated: PropTypes.bool,
admin: PropTypes.bool,
polochonToken: PropTypes.string,
polochonId: PropTypes.string,
polochonActivated: PropTypes.bool,
}; };

View File

@ -8,13 +8,17 @@ import { getPolochons } from "../../actions/polochon";
export const UserList = () => { export const UserList = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const users = useSelector((state) => state.adminStore.get("users")); const userIds = useSelector((state) => Array.from(state.admin.users.keys()));
useEffect(() => { useEffect(() => {
dispatch(getUsers()); dispatch(getUsers());
dispatch(getPolochons()); dispatch(getPolochons());
}, [dispatch]); }, [dispatch]);
if (userIds.length === 0) {
return null;
}
return ( return (
<div className="table-responsive my-2"> <div className="table-responsive my-2">
<table className="table table-striped"> <table className="table table-striped">
@ -31,19 +35,8 @@ export const UserList = () => {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{users.map((el, index) => ( {userIds.map((id) => (
<User <User key={id} id={id} />
key={index}
id={el.get("id")}
name={el.get("name")}
admin={el.get("admin")}
activated={el.get("activated")}
polochonActivated={el.get("polochon_activated")}
token={el.get("token", "")}
polochonId={el.getIn(["polochon", "id"], "")}
polochonUrl={el.getIn(["polochon", "url"], "")}
polochonName={el.getIn(["polochon", "name"], "")}
/>
))} ))}
</tbody> </tbody>
</table> </table>

View File

@ -1,172 +0,0 @@
import React, { useState } from "react";
import PropTypes from "prop-types";
import { List } from "immutable";
import { Button, Modal } from "react-bootstrap";
import Toggle from "react-bootstrap-toggle";
export const UserList = (props) => (
<div className="table-responsive my-2">
<table className="table table-striped">
<thead className="table-secondary">
<tr>
<th>#</th>
<th>Name</th>
<th>Activated</th>
<th>Admin</th>
<th>Polochon URL</th>
<th>Polochon token</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{props.users.map((el, index) => (
<User key={index} data={el} updateUser={props.updateUser} />
))}
</tbody>
</table>
</div>
);
UserList.propTypes = {
users: PropTypes.PropTypes.instanceOf(List),
updateUser: PropTypes.func,
};
const User = function (props) {
const polochonConfig = props.data.get("RawConfig").get("polochon");
const polochonUrl = polochonConfig ? polochonConfig.get("url") : "-";
const polochonToken = polochonConfig ? polochonConfig.get("token") : "-";
return (
<tr>
<td>{props.data.get("id")}</td>
<td>{props.data.get("Name")}</td>
<td>
<UserActivationStatus activated={props.data.get("Activated")} />
</td>
<td>
<UserAdminStatus admin={props.data.get("Admin")} />
</td>
<td>{polochonUrl}</td>
<td>{polochonToken}</td>
<td>
<UserEdit
data={props.data}
updateUser={props.updateUser}
polochonUrl={polochonUrl}
polochonToken={polochonToken}
/>
</td>
</tr>
);
};
User.propTypes = {
data: PropTypes.object,
updateUser: PropTypes.func,
};
const UserAdminStatus = (props) => (
<span className={props.admin ? "fa fa-check" : "fa fa-times"}></span>
);
UserAdminStatus.propTypes = { admin: PropTypes.bool.isRequired };
const UserActivationStatus = (props) => (
<span
className={props.activated ? "fa fa-check" : "fa fa-times text-danger"}
></span>
);
UserActivationStatus.propTypes = { activated: PropTypes.bool.isRequired };
function UserEdit(props) {
const [modal, setModal] = useState(false);
const [admin, setAdmin] = useState(props.data.get("Admin"));
const [activated, setActivated] = useState(props.data.get("Activated"));
const [url, setUrl] = useState(props.polochonUrl);
const [token, setToken] = useState(props.polochonToken);
const handleSubmit = function (e) {
if (e) {
e.preventDefault();
}
props.updateUser({
userId: props.data.get("id"),
admin: admin,
activated: activated,
polochonUrl: url,
polochonToken: token,
});
setModal(false);
};
return (
<span>
<span
className="fa fa-pencil clickable"
onClick={() => setModal(true)}
></span>
<Modal show={modal} onHide={() => setModal(false)}>
<Modal.Header closeButton>
<Modal.Title>
<i className="fa fa-pencil"></i>
&nbsp; Edit user - {props.data.get("Name")}
</Modal.Title>
</Modal.Header>
<Modal.Body bsPrefix="modal-body admin-edit-user-modal">
<form className="form-horizontal" onSubmit={(ev) => handleSubmit(ev)}>
<div className="form-group">
<label>Account status</label>
<Toggle
className="pull-right"
on="Activated"
off="Deactivated"
active={activated}
offstyle="danger"
handlestyle="secondary"
onClick={() => setActivated(!activated)}
/>
</div>
<div className="form-group">
<label>Admin status</label>
<Toggle
className="pull-right"
on="Admin"
off="User"
active={admin}
offstyle="info"
handlestyle="secondary"
onClick={() => setAdmin(!admin)}
/>
</div>
<div className="form-group">
<label className="control-label">Polochon URL</label>
<input
className="form-control"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div>
<div className="form-group">
<label className="control-label">Polochon token</label>
<input
className="form-control"
value={token}
onChange={(e) => setToken(e.target.value)}
/>
</div>
</form>
</Modal.Body>
<Modal.Footer>
<Button variant="success" onClick={handleSubmit}>
Apply
</Button>
<Button onClick={() => setModal(false)}>Close</Button>
</Modal.Footer>
</Modal>
</span>
);
}
UserEdit.propTypes = {
data: PropTypes.object,
updateUser: PropTypes.func,
polochonUrl: PropTypes.string,
polochonToken: PropTypes.string,
};

View File

@ -4,24 +4,20 @@ import { useSelector, useDispatch } from "react-redux";
import { dismissAlert } from "../../actions/alerts"; import { dismissAlert } from "../../actions/alerts";
const Alert = () => { export const Alert = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const show = useSelector((state) => state.alerts.get("show")); const show = useSelector((state) => state.alerts.show);
const title = useSelector((state) => state.alerts.get("message")); const title = useSelector((state) => state.alerts.message);
const type = useSelector((state) => state.alerts.get("type")); const type = useSelector((state) => state.alerts.type);
const dismiss = () => {
dispatch(dismissAlert());
};
if (!show) { if (!show) {
return null; return null;
} }
return ( return <SweetAlert type={type} title={title} onConfirm={dismiss} />;
<SweetAlert
type={type}
title={title}
onConfirm={() => dispatch(dismissAlert())}
/>
);
}; };
export default Alert;

View File

@ -1,6 +1,5 @@
import React, { useState } from "react"; import React, { useState } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { List } from "immutable";
import Modal from "react-bootstrap/Modal"; import Modal from "react-bootstrap/Modal";
@ -19,7 +18,7 @@ export const DownloadAndStream = ({ url, name, subtitles }) => {
DownloadAndStream.propTypes = { DownloadAndStream.propTypes = {
url: PropTypes.string, url: PropTypes.string,
name: PropTypes.string, name: PropTypes.string,
subtitles: PropTypes.instanceOf(List), subtitles: PropTypes.array,
}; };
const DownloadButton = ({ url }) => ( const DownloadButton = ({ url }) => (
@ -68,25 +67,23 @@ const StreamButton = ({ name, url, subtitles }) => {
StreamButton.propTypes = { StreamButton.propTypes = {
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
url: PropTypes.string.isRequired, url: PropTypes.string.isRequired,
subtitles: PropTypes.instanceOf(List), subtitles: PropTypes.array,
}; };
const Player = ({ url, subtitles }) => { const Player = ({ url, subtitles }) => {
const hasSubtitles = !(subtitles === null || subtitles.size === 0); const subs = subtitles || [];
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>
<source src={url} type="video/mp4" /> <source src={url} type="video/mp4" />
{hasSubtitles && {subs.map((sub, index) => (
subtitles
.toIndexedSeq()
.map((el, index) => (
<track <track
key={index} key={index}
kind="subtitles" kind="subtitles"
label={el.get("language")} label={sub.language}
src={el.get("vvt_file")} src={sub.vvt_file}
srcLang={el.get("language")} srcLang={sub.language}
/> />
))} ))}
</video> </video>
@ -94,9 +91,6 @@ const Player = ({ url, subtitles }) => {
); );
}; };
Player.propTypes = { Player.propTypes = {
subtitles: PropTypes.instanceOf(List), subtitles: PropTypes.array,
url: PropTypes.string.isRequired, url: PropTypes.string.isRequired,
}; };
Player.defaultProps = {
subtitles: List(),
};

View File

@ -1,6 +1,5 @@
import React, { useState } from "react"; import React, { useState } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { List } from "immutable";
import Dropdown from "react-bootstrap/Dropdown"; import Dropdown from "react-bootstrap/Dropdown";
@ -30,7 +29,7 @@ export const SubtitlesButton = ({
} }
}; };
const count = subtitles && subtitles.size !== 0 ? subtitles.size : 0; const count = subtitles && subtitles.length !== 0 ? subtitles.length : 0;
return ( return (
<span className="mr-1 mb-1"> <span className="mr-1 mb-1">
<Dropdown drop="up" show={show} onToggle={onToggle} onSelect={onSelect}> <Dropdown drop="up" show={show} onToggle={onToggle} onSelect={onSelect}>
@ -54,9 +53,9 @@ export const SubtitlesButton = ({
</React.Fragment> </React.Fragment>
)} )}
{count > 0 && {count > 0 &&
subtitles.toIndexedSeq().map((subtitle, index) => ( subtitles.map((subtitle, index) => (
<Dropdown.Item href={subtitle.get("url")} key={index}> <Dropdown.Item href={subtitle.url} key={index}>
{subtitle.get("language").split("_")[1]} {subtitle.language.split("_")[1]}
</Dropdown.Item> </Dropdown.Item>
))} ))}
</Dropdown.Menu> </Dropdown.Menu>
@ -65,7 +64,7 @@ export const SubtitlesButton = ({
); );
}; };
SubtitlesButton.propTypes = { SubtitlesButton.propTypes = {
subtitles: PropTypes.instanceOf(List), subtitles: PropTypes.array,
inLibrary: PropTypes.bool.isRequired, inLibrary: PropTypes.bool.isRequired,
searching: PropTypes.bool.isRequired, searching: PropTypes.bool.isRequired,
search: PropTypes.func.isRequired, search: PropTypes.func.isRequired,

View File

@ -1,6 +1,5 @@
import React, { useState } from "react"; import React, { useState } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { List } from "immutable";
import { useDispatch } from "react-redux"; import { useDispatch } from "react-redux";
import { prettySize } from "../../utils"; import { prettySize } from "../../utils";
@ -8,49 +7,60 @@ import { addTorrent } from "../../actions/torrents";
import Dropdown from "react-bootstrap/Dropdown"; import Dropdown from "react-bootstrap/Dropdown";
function buildMenuItems(torrents) { const buildMenuItems = (torrents) => {
if (!torrents || torrents.size === 0) { if (!torrents || torrents.length === 0) {
return []; return [];
} }
const t = torrents.groupBy((el) => el.get("source"));
// Build the array of entries // Build the array of entries
let entries = []; let entries = [];
let dividerCount = t.size - 1;
for (let [source, torrentList] of t.entrySeq()) { let dividerCount = torrents.size - 1;
torrents.forEach((torrentsBySource, source) => {
// Push the title // Push the title
entries.push({ entries.push({
type: "header", type: "header",
value: source, value: source,
}); });
// Push the torrents torrentsBySource.forEach((torrent) => {
for (let torrent of torrentList) {
entries.push({ entries.push({
type: "entry", type: "entry",
quality: torrent.get("quality"), quality: torrent.quality,
url: torrent.get("url"), url: torrent.url,
size: torrent.get("size"), size: torrent.size,
});
}); });
}
// Push the divider // Push the divider
if (dividerCount > 0) { if (dividerCount > 0) {
dividerCount--; dividerCount--;
entries.push({ type: "divider" }); entries.push({ type: "divider" });
} }
} });
return entries; return entries;
};
const countEntries = (torrents) => {
if (!torrents || torrents.length === 0) {
return 0;
} }
let count = 0;
torrents.forEach((source) => {
count = count + source.size;
});
return count;
};
export const TorrentsButton = ({ torrents, search, searching, url }) => { export const TorrentsButton = ({ torrents, search, searching, url }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
const entries = buildMenuItems(torrents); const entries = buildMenuItems(torrents);
const count = torrents && torrents.size !== 0 ? torrents.size : 0; const count = countEntries(torrents);
const onSelect = (eventKey) => { const onSelect = (eventKey) => {
// Close the dropdown if the eventkey is not specified // Close the dropdown if the eventkey is not specified
@ -115,11 +125,8 @@ export const TorrentsButton = ({ torrents, search, searching, url }) => {
); );
}; };
TorrentsButton.propTypes = { TorrentsButton.propTypes = {
torrents: PropTypes.instanceOf(List), torrents: PropTypes.object,
searching: PropTypes.bool, searching: PropTypes.bool,
search: PropTypes.func.isRequired, search: PropTypes.func.isRequired,
url: PropTypes.string, url: PropTypes.string,
}; };
TorrentsButton.defaultProps = {
torrents: List(),
};

View File

@ -1,15 +1,13 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { List } from "immutable";
export const Genres = ({ genres }) => { export const Genres = ({ genres = [] }) => {
if (genres.size === 0) { if (!genres || genres.length === 0) {
return null; return null;
} }
// Uppercase first genres // Uppercase first genres
const prettyGenres = genres const prettyGenres = genres
.toJS()
.map((word) => word[0].toUpperCase() + word.substr(1)) .map((word) => word[0].toUpperCase() + word.substr(1))
.join(", "); .join(", ");
@ -20,5 +18,4 @@ export const Genres = ({ genres }) => {
</span> </span>
); );
}; };
Genres.propTypes = { genres: PropTypes.instanceOf(List) }; Genres.propTypes = { genres: PropTypes.array };
Genres.defaultProps = { genres: List() };

View File

@ -1,8 +1,5 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { Map } from "immutable";
import { inLibrary, isWishlisted } from "../../utils";
import { DownloadAndStream } from "../buttons/download"; import { DownloadAndStream } from "../buttons/download";
import { ImdbBadge } from "../buttons/imdb"; import { ImdbBadge } from "../buttons/imdb";
@ -17,9 +14,10 @@ import { Runtime } from "../details/runtime";
import { Title } from "../details/title"; import { Title } from "../details/title";
const ListDetails = (props) => { const ListDetails = (props) => {
if (props.data === undefined) { if (!props.data || Object.keys(props.data).length === 0) {
return null; return null;
} }
if (props.loading) { if (props.loading) {
return null; return null;
} }
@ -27,46 +25,44 @@ const ListDetails = (props) => {
return ( return (
<div className="col-8 col-md-4 list-details pl-1 d-flex align-items-start flex-column video-details flex-fill flex-column flex-nowrap"> <div className="col-8 col-md-4 list-details pl-1 d-flex align-items-start flex-column video-details flex-fill flex-column flex-nowrap">
<Title <Title
title={props.data.get("title")} title={props.data.title}
wishlist={props.wishlist} wishlist={props.wishlist}
wishlisted={isWishlisted(props.data)} wishlisted={props.wishlisted}
/>
<ReleaseDate date={props.data.get("year")} />
<Runtime runtime={props.data.get("runtime")} />
<Genres genres={props.data.get("genres")} />
<Rating
rating={props.data.get("rating")}
votes={props.data.get("votes")}
/> />
<ReleaseDate date={props.data.year} />
<Runtime runtime={props.data.runtime} />
<Genres genres={props.data.genres} />
<Rating rating={props.data.rating} votes={props.data.votes} />
<div> <div>
<ImdbBadge imdbId={props.data.get("imdb_id")} /> <ImdbBadge imdbId={props.data.imdb_id} />
<DownloadAndStream <DownloadAndStream
url={props.data.get("polochon_url")} url={props.data.polochon_url}
name={props.data.get("title")} name={props.data.title}
subtitles={props.data.get("subtitles")} subtitles={props.data.subtitles}
/> />
</div> </div>
<TrackingLabel <TrackingLabel
wishlisted={props.data.get("wishlisted")} wishlisted={props.data.wishlisted}
inLibrary={inLibrary(props.data)} inLibrary={props.data.polochon_url !== ""}
trackedSeason={props.data.get("tracked_season")} trackedSeason={props.data.tracked_season}
trackedEpisode={props.data.get("tracked_episode")} trackedEpisode={props.data.tracked_episode}
/> />
<PolochonMetadata <PolochonMetadata
quality={props.data.get("quality")} quality={props.data.quality}
releaseGroup={props.data.get("release_group")} releaseGroup={props.data.release_group}
container={props.data.get("container")} container={props.data.container}
audioCodec={props.data.get("audio_codec")} audioCodec={props.data.audio_codec}
videoCodec={props.data.get("video_codec")} videoCodec={props.data.video_codec}
/> />
<Plot plot={props.data.get("plot")} /> <Plot plot={props.data.plot} />
{props.children} {props.children}
</div> </div>
); );
}; };
ListDetails.propTypes = { ListDetails.propTypes = {
data: PropTypes.instanceOf(Map), data: PropTypes.object,
wishlist: PropTypes.func, wishlist: PropTypes.func,
wishlisted: PropTypes.bool,
loading: PropTypes.bool, loading: PropTypes.bool,
children: PropTypes.object, children: PropTypes.object,
}; };

View File

@ -31,7 +31,7 @@ const ExplorerOptions = ({
const handleSourceChange = (event) => { const handleSourceChange = (event) => {
let source = event.target.value; let source = event.target.value;
let category = options.get(event.target.value).first(); let category = options[event.target.value][0];
history.push(`/${type}/explore/${source}/${category}`); history.push(`/${type}/explore/${source}/${category}`);
}; };
@ -50,13 +50,13 @@ const ExplorerOptions = ({
}; };
// Options are not yet fetched // Options are not yet fetched
if (options.size === 0) { if (Object.keys(options).length === 0) {
return null; return null;
} }
let source = params.source; let source = params.source;
let category = params.category; let category = params.category;
let categories = options.get(params.source); let categories = options[params.source];
return ( return (
<div className="row"> <div className="row">
@ -72,7 +72,7 @@ const ExplorerOptions = ({
onChange={handleSourceChange} onChange={handleSourceChange}
value={source} value={source}
> >
{options.keySeq().map(function (source) { {Object.keys(options).map(function (source) {
return ( return (
<option key={source} value={source}> <option key={source} value={source}>
{prettyName(source)} {prettyName(source)}

View File

@ -8,6 +8,8 @@ const ListFilter = ({ placeHolder, updateFilter }) => {
// Start filtering at 3 chars // Start filtering at 3 chars
if (filter.length >= 3) { if (filter.length >= 3) {
updateFilter(filter); updateFilter(filter);
} else {
updateFilter("");
} }
}, [filter, updateFilter]); }, [filter, updateFilter]);

View File

@ -0,0 +1,106 @@
import React, { useEffect, useCallback, useRef } from "react";
import PropTypes from "prop-types";
export const KeyboardNavigation = ({
onKeyEnter,
selectPoster,
children,
list,
selected,
}) => {
const containerRef = useRef(null);
useEffect(() => {
document.onkeypress = move;
return () => {
document.onkeypress = null;
};
}, [move, list, selected]);
const move = useCallback(
(event) => {
// Only run the function if nothing else if actively focused
if (document.activeElement.tagName.toLowerCase() !== "body") {
return;
}
// Compute the grid dimentions
if (containerRef === null) {
return;
}
const containerWidth = containerRef.current.getBoundingClientRect().width;
const posterContainer = containerRef.current.getElementsByClassName(
"img-thumbnail"
);
let posterWidth = 0;
if (posterContainer !== null && posterContainer.item(0) !== null) {
const poster = posterContainer.item(0);
posterWidth =
poster.getBoundingClientRect().width +
poster.getBoundingClientRect().left;
}
let postersPerRow =
posterWidth >= containerWidth
? 1
: Math.floor(containerWidth / posterWidth);
let diff = 0;
switch (event.key) {
case "Enter":
onKeyEnter(selected);
return;
case "l":
diff = 1;
break;
case "h":
diff = -1;
break;
case "k":
diff = -1 * postersPerRow;
break;
case "j":
diff = postersPerRow;
break;
default:
return;
}
// Get the index of the currently selected item
const idx = list.findIndex((e) => e.imdbId === selected);
var newIdx = idx + diff;
// Handle edge cases
if (newIdx > list.length - 1) {
newIdx = list.length - 1;
} else if (newIdx < 0) {
newIdx = 0;
}
// Get the imdbID of the newly selected item
var selectedImdb = list[newIdx].imdbId;
// Select the movie
selectPoster(selectedImdb);
containerRef.current
.getElementsByClassName("img-thumbnail")
.item(newIdx)
.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "nearest",
});
},
[list, onKeyEnter, selectPoster, selected]
);
return <div ref={containerRef}>{children}</div>;
};
KeyboardNavigation.propTypes = {
list: PropTypes.array,
selected: PropTypes.string,
onKeyEnter: PropTypes.func,
selectPoster: PropTypes.func,
children: PropTypes.object,
};

View File

@ -1,94 +1,115 @@
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { OrderedMap, Map } from "immutable";
import fuzzy from "fuzzy"; import fuzzy from "fuzzy";
import InfiniteScroll from "react-infinite-scroll-component"; import InfiniteScroll from "react-infinite-scroll-component";
import ListFilter from "./filter"; import ListFilter from "./filter";
import ExplorerOptions from "./explorerOptions"; import ExplorerOptions from "./explorerOptions";
import Poster from "./poster"; import Poster from "./poster";
import { KeyboardNavigation } from "./keyboard";
import Loader from "../loader/loader"; import Loader from "../loader/loader";
const ListPosters = (props) => { const ListPosters = ({
if (props.loading) { data,
return <Loader />; exploreFetchOptions,
} exploreOptions,
loading,
onClick,
onDoubleClick,
onKeyEnter,
params,
placeHolder,
selectedImdbId,
type,
}) => {
const [list, setList] = useState([]);
const [filteredList, setFilteredList] = useState([]);
const [filter, setFilter] = useState("");
let elmts = props.data; useEffect(() => {
const listSize = elmts !== undefined ? elmts.size : 0; let l = [];
data.forEach((e) => {
l.push({
imdbId: e.imdb_id,
posterUrl: e.poster_url,
title: e.title,
});
});
setList(l);
}, [data]);
// Filter the list of elements useEffect(() => {
if (props.filter !== "") { if (filter !== "" && filter.length >= 3) {
elmts = elmts.filter((v) => fuzzy.test(props.filter, v.get("title"))); setFilteredList(list.filter((e) => fuzzy.test(filter, e.title)));
} else { } else {
elmts = elmts.slice(0, listSize.items); setFilteredList(list);
}
}, [filter, list]);
if (loading) {
return <Loader />;
} }
// Chose when to display filter / explore options // Chose when to display filter / explore options
let displayFilter = true; let displayFilter = true;
if ( if (
(props.params && (params &&
props.params.category && params.category &&
props.params.category !== "" && params.category !== "" &&
props.params.source && params.source &&
props.params.source !== "") || params.source !== "") ||
listSize === 0 list.length === 0
) { ) {
displayFilter = false; displayFilter = false;
} }
let displayExplorerOptions = false; let displayExplorerOptions = false;
if (listSize !== 0) { if (list.length !== 0) {
displayExplorerOptions = !displayFilter; displayExplorerOptions = !displayFilter;
} }
return ( return (
<div className="col-4 col-md-8 px-1"> <div className="col-4 col-md-8 px-1">
{displayFilter && ( {displayFilter && (
<ListFilter <ListFilter updateFilter={setFilter} placeHolder={placeHolder} />
updateFilter={props.updateFilter}
placeHolder={props.placeHolder}
/>
)} )}
<ExplorerOptions <ExplorerOptions
type={props.type} type={type}
display={displayExplorerOptions} display={displayExplorerOptions}
params={props.params} params={params}
fetch={props.exploreFetchOptions} fetch={exploreFetchOptions}
options={props.exploreOptions} options={exploreOptions}
/> />
<Posters <Posters
elmts={elmts} list={filteredList}
loading={props.loading} loading={loading}
selectedImdbId={props.selectedImdbId} selectedImdbId={selectedImdbId}
selectPoster={props.onClick} selectPoster={onClick}
onDoubleClick={props.onDoubleClick} onDoubleClick={onDoubleClick}
onKeyEnter={props.onKeyEnter} onKeyEnter={onKeyEnter}
/> />
</div> </div>
); );
}; };
ListPosters.propTypes = { ListPosters.propTypes = {
data: PropTypes.instanceOf(OrderedMap), data: PropTypes.object,
onClick: PropTypes.func, onClick: PropTypes.func,
onDoubleClick: PropTypes.func, onDoubleClick: PropTypes.func,
onKeyEnter: PropTypes.func, onKeyEnter: PropTypes.func,
selectedImdbId: PropTypes.string, selectedImdbId: PropTypes.string,
loading: PropTypes.bool.isRequired, loading: PropTypes.bool.isRequired,
params: PropTypes.object.isRequired, params: PropTypes.object.isRequired,
exploreOptions: PropTypes.instanceOf(Map), exploreOptions: PropTypes.object,
type: PropTypes.string.isRequired, type: PropTypes.string.isRequired,
placeHolder: PropTypes.string.isRequired, placeHolder: PropTypes.string.isRequired,
updateFilter: PropTypes.func.isRequired,
filter: PropTypes.string,
exploreFetchOptions: PropTypes.func, exploreFetchOptions: PropTypes.func,
}; };
export default ListPosters; export default ListPosters;
const Posters = ({ const Posters = ({
elmts, list,
onKeyEnter, onKeyEnter,
selectedImdbId, selectedImdbId,
selectPoster, selectPoster,
@ -96,120 +117,20 @@ const Posters = ({
}) => { }) => {
const addMoreCount = 20; const addMoreCount = 20;
const [size, setSize] = useState(0); const [size, setSize] = useState(0);
const [postersPerRow, setPostersPerRow] = useState(0);
const [posterHeight, setPosterHeight] = useState(0);
const [initialLoading, setInitialLoading] = useState(true);
const loadMore = useCallback(() => { const updateSize = (newSize, maxSize) => {
if (size === elmts.size) { setSize(Math.min(newSize, maxSize));
return;
}
const newSize =
size + addMoreCount >= elmts.size ? elmts.size : size + addMoreCount;
setSize(newSize);
}, [size, elmts.size]);
useEffect(() => {
if (initialLoading) {
loadMore();
setInitialLoading(false);
}
}, [loadMore, initialLoading, setInitialLoading]);
const move = useCallback(
(event) => {
// Only run the function if nothing else if actively focused
if (document.activeElement.tagName.toLowerCase() !== "body") {
return;
}
let diff = 0;
let moveFocus = 0;
switch (event.key) {
case "Enter":
onKeyEnter(selectedImdbId);
return;
case "l":
diff = 1;
break;
case "h":
diff = -1;
break;
case "k":
diff = -1 * postersPerRow;
moveFocus = -1 * posterHeight;
break;
case "j":
diff = postersPerRow;
moveFocus = posterHeight;
break;
default:
return;
}
// Get the index of the currently selected item
const idx = elmts.keySeq().findIndex((k) => k === selectedImdbId);
var newIdx = idx + diff;
// Handle edge cases
if (newIdx > elmts.size - 1) {
newIdx = elmts.size - 1;
} else if (newIdx < 0) {
newIdx = 0;
}
// Get the imdbID of the newly selected item
var selectedImdb = Object.keys(elmts.toJS())[newIdx];
// Select the movie
selectPoster(selectedImdb);
if (moveFocus !== 0) {
window.scrollBy(0, moveFocus);
}
},
[
elmts,
onKeyEnter,
posterHeight,
postersPerRow,
selectPoster,
selectedImdbId,
]
);
const posterCount = (node) => {
if (node === null) {
return;
}
const parentWidth = node.getBoundingClientRect().width;
const childContainer = node.getElementsByClassName("img-thumbnail");
let childWidth = 0;
let posterHeight = 0;
if (childContainer !== null && childContainer.item(0) !== null) {
const child = childContainer.item(0);
childWidth =
child.getBoundingClientRect().width +
child.getBoundingClientRect().left;
posterHeight = child.getBoundingClientRect().height;
}
let numberPerRow =
childWidth >= parentWidth ? 1 : Math.floor(parentWidth / childWidth);
setPostersPerRow(numberPerRow);
setPosterHeight(posterHeight);
}; };
useEffect(() => { useEffect(() => {
document.onkeypress = move; updateSize(size + addMoreCount, list.length);
return () => { }, [list]); // eslint-disable-line react-hooks/exhaustive-deps
document.onkeypress = null;
};
}, [move]);
if (elmts.size === 0) { const loadMore = () => {
updateSize(size + addMoreCount, list.length);
};
if (list.length === 0) {
return ( return (
<div className="jumbotron"> <div className="jumbotron">
<h2>No result</h2> <h2>No result</h2>
@ -218,39 +139,38 @@ const Posters = ({
} }
return ( return (
<div ref={posterCount}> <KeyboardNavigation
onKeyEnter={onKeyEnter}
selectPoster={selectPoster}
list={list}
selected={selectedImdbId}
>
<InfiniteScroll <InfiniteScroll
className="poster-list d-flex flex-column flex-sm-row flex-sm-wrap justify-content-around" className="poster-list d-flex flex-column flex-sm-row flex-sm-wrap justify-content-around"
dataLength={size} dataLength={size}
next={loadMore} next={loadMore}
hasMore={size !== elmts.size} hasMore={size !== list.length}
loader={<Loader />} loader={<Loader />}
> >
{elmts {list.slice(0, size).map((el, index) => {
.slice(0, size) const imdbId = el.imdbId;
.toIndexedSeq()
.map(function (el, index) {
const imdbId = el.get("imdb_id");
const selected = imdbId === selectedImdbId ? true : false;
return ( return (
<Poster <Poster
url={el.get("poster_url")} url={el.posterUrl}
key={index} key={index}
selected={selected} selected={imdbId === selectedImdbId}
onClick={() => selectPoster(imdbId)} onClick={() => selectPoster(imdbId)}
onDoubleClick={() => onDoubleClick(imdbId)} onDoubleClick={() => onDoubleClick(imdbId)}
/> />
); );
}, this)} }, this)}
</InfiniteScroll> </InfiniteScroll>
</div> </KeyboardNavigation>
); );
}; };
Posters.propTypes = { Posters.propTypes = {
elmts: PropTypes.instanceOf(OrderedMap), list: PropTypes.array.isRequired,
selectedImdbId: PropTypes.string, selectedImdbId: PropTypes.string,
loading: PropTypes.bool.isRequired,
onDoubleClick: PropTypes.func, onDoubleClick: PropTypes.func,
onKeyEnter: PropTypes.func, onKeyEnter: PropTypes.func,
selectPoster: PropTypes.func, selectPoster: PropTypes.func,

View File

@ -1,65 +1,57 @@
import React from "react"; import React from "react";
import Loader from "../loader/loader"; import Loader from "../loader/loader";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { Map, List } from "immutable";
// TODO: udpate this // TODO: udpate this
import { OverlayTrigger, Tooltip } from "react-bootstrap"; import { OverlayTrigger, Tooltip } from "react-bootstrap";
const Modules = (props) => { const Modules = ({ isLoading, modules }) => {
if (props.isLoading) { if (isLoading) {
return <Loader />; return <Loader />;
} }
return ( return (
<div className="row"> <div className="row">
{props.modules && {Object.keys(modules).map((type) => (
props.modules <ModulesByVideoType key={type} type={type} modules={modules[type]} />
.keySeq()
.map((value, key) => (
<ModulesByVideoType
key={key}
videoType={value}
data={props.modules.get(value)}
/>
))} ))}
</div> </div>
); );
}; };
Modules.propTypes = { Modules.propTypes = {
isLoading: PropTypes.bool.isRequired, isLoading: PropTypes.bool.isRequired,
modules: PropTypes.instanceOf(Map), modules: PropTypes.object.isRequired,
}; };
export default Modules; export default Modules;
const capitalize = (string) => string.charAt(0).toUpperCase() + string.slice(1); const capitalize = (string) => string.charAt(0).toUpperCase() + string.slice(1);
const ModulesByVideoType = (props) => ( const ModulesByVideoType = ({ type, modules }) => (
<div className="col-12 col-md-6"> <div className="col-12 col-md-6">
<div className="card mb-3"> <div className="card mb-3">
<div className="card-header"> <div className="card-header">
<h3>{`${capitalize(props.videoType)} modules`}</h3> <h3>{`${capitalize(type)} modules`}</h3>
</div> </div>
<div className="card-body"> <div className="card-body">
{props.data.keySeq().map((value, key) => ( {Object.keys(modules).map((moduleType, i) => (
<ModuleByType key={key} type={value} data={props.data.get(value)} /> <ModuleByType key={i} type={type} modules={modules[moduleType]} />
))} ))}
</div> </div>
</div> </div>
</div> </div>
); );
ModulesByVideoType.propTypes = { ModulesByVideoType.propTypes = {
videoType: PropTypes.string.isRequired, type: PropTypes.string.isRequired,
data: PropTypes.instanceOf(Map), modules: PropTypes.object.isRequired,
}; };
const ModuleByType = (props) => ( const ModuleByType = ({ type, modules }) => (
<div> <div>
<h4>{capitalize(props.type)}</h4> <h4>{capitalize(type)}</h4>
<table className="table"> <table className="table">
<tbody> <tbody>
{props.data.map(function (value, key) { {modules.map((module, type) => {
return <Module key={key} type={key} data={value} />; return <Module key={type} type={type} module={module} />;
})} })}
</tbody> </tbody>
</table> </table>
@ -67,14 +59,13 @@ const ModuleByType = (props) => (
); );
ModuleByType.propTypes = { ModuleByType.propTypes = {
type: PropTypes.string.isRequired, type: PropTypes.string.isRequired,
data: PropTypes.instanceOf(List), modules: PropTypes.array.isRequired,
}; };
const Module = (props) => { const Module = ({ module }) => {
let iconClass, prettyStatus, badgeClass; let iconClass, prettyStatus, badgeClass;
const name = props.data.get("name");
switch (props.data.get("status")) { switch (module.status) {
case "ok": case "ok":
iconClass = "fa fa-check-circle"; iconClass = "fa fa-check-circle";
badgeClass = "badge badge-pill badge-success"; badgeClass = "badge badge-pill badge-success";
@ -97,19 +88,17 @@ const Module = (props) => {
} }
const tooltip = ( const tooltip = (
<Tooltip id={`tooltip-status-${name}`}> <Tooltip id={`tooltip-status-${module.name}}`}>
<p> <p>
<span className={badgeClass}>Status: {prettyStatus}</span> <span className={badgeClass}>Status: {prettyStatus}</span>
</p> </p>
{props.data.get("error") !== "" && ( {module.error !== "" && <p>Error: {module.error}</p>}
<p>Error: {props.data.get("error")}</p>
)}
</Tooltip> </Tooltip>
); );
return ( return (
<tr> <tr>
<th>{name}</th> <th>{module.name}</th>
<td> <td>
<OverlayTrigger placement="right" overlay={tooltip}> <OverlayTrigger placement="right" overlay={tooltip}>
<span className={badgeClass}> <span className={badgeClass}>
@ -121,5 +110,5 @@ const Module = (props) => {
); );
}; };
Module.propTypes = { Module.propTypes = {
data: PropTypes.instanceOf(Map), module: PropTypes.object.isRequired,
}; };

View File

@ -1,11 +1,9 @@
import React, { useEffect, useCallback } from "react"; import React, { useEffect, useCallback } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { useSelector, useDispatch } from "react-redux"; import { useSelector, useDispatch } from "react-redux";
import { Map } from "immutable";
import { import {
selectMovie, selectMovie,
updateFilter,
movieWishlistToggle, movieWishlistToggle,
fetchMovies, fetchMovies,
getMovieExploreOptions, getMovieExploreOptions,
@ -14,8 +12,6 @@ import {
import ListDetails from "../list/details"; import ListDetails from "../list/details";
import ListPosters from "../list/posters"; import ListPosters from "../list/posters";
import { inLibrary, isWishlisted } from "../../utils";
import { ShowMore } from "../buttons/showMore"; import { ShowMore } from "../buttons/showMore";
import { MovieSubtitlesButton } from "./subtitlesButton"; import { MovieSubtitlesButton } from "./subtitlesButton";
@ -51,25 +47,17 @@ const MovieList = ({ match }) => {
} }
}, [dispatch, match]); }, [dispatch, match]);
const loading = useSelector((state) => state.movieStore.get("loading")); const loading = useSelector((state) => state.movies.loading);
const movies = useSelector((state) => state.movieStore.get("movies")); const movies = useSelector((state) => state.movies.movies);
const filter = useSelector((state) => state.movieStore.get("filter")); const selectedImdbId = useSelector((state) => state.movies.selectedImdbId);
const selectedImdbId = useSelector((state) => const exploreOptions = useSelector((state) => state.movies.exploreOptions);
state.movieStore.get("selectedImdbId")
);
const exploreOptions = useSelector((state) =>
state.movieStore.get("exploreOptions")
);
let selectedMovie = Map(); let selectedMovie = {};
if (movies !== undefined && movies.has(selectedImdbId)) { if (movies !== undefined && movies.has(selectedImdbId)) {
selectedMovie = movies.get(selectedImdbId); selectedMovie = movies.get(selectedImdbId);
} }
const selectFunc = useCallback((id) => dispatch(selectMovie(id)), [dispatch]); const selectFunc = useCallback((id) => dispatch(selectMovie(id)), [dispatch]);
const filterFunc = useCallback((filter) => dispatch(updateFilter(filter)), [
dispatch,
]);
const exploreFetchOptions = useCallback( const exploreFetchOptions = useCallback(
() => dispatch(getMovieExploreOptions()), () => dispatch(getMovieExploreOptions()),
[dispatch] [dispatch]
@ -84,8 +72,6 @@ const MovieList = ({ match }) => {
exploreFetchOptions={exploreFetchOptions} exploreFetchOptions={exploreFetchOptions}
exploreOptions={exploreOptions} exploreOptions={exploreOptions}
selectedImdbId={selectedImdbId} selectedImdbId={selectedImdbId}
updateFilter={filterFunc}
filter={filter}
onClick={selectFunc} onClick={selectFunc}
onDoubleClick={() => {}} onDoubleClick={() => {}}
onKeyEnter={() => {}} onKeyEnter={() => {}}
@ -95,31 +81,19 @@ const MovieList = ({ match }) => {
<ListDetails <ListDetails
data={selectedMovie} data={selectedMovie}
loading={loading} loading={loading}
wishlisted={selectedMovie.wishlisted}
wishlist={() => wishlist={() =>
dispatch( dispatch(
movieWishlistToggle( movieWishlistToggle(selectedMovie.imdb_id, selectedMovie.wishlisted)
selectedMovie.get("imdb_id"),
isWishlisted(selectedMovie)
)
) )
} }
> >
<ShowMore <ShowMore
id={selectedMovie.get("imdb_id")} id={selectedMovie.imdb_id}
inLibrary={inLibrary(selectedMovie)} inLibrary={selectedMovie.polochon_url !== ""}
> >
<MovieTorrentsButton <MovieTorrentsButton />
torrents={selectedMovie.get("torrents")} <MovieSubtitlesButton />
imdbId={selectedMovie.get("imdb_id")}
title={selectedMovie.get("title")}
searching={selectedMovie.get("fetchingDetails", false)}
/>
<MovieSubtitlesButton
subtitles={selectedMovie.get("subtitles")}
inLibrary={inLibrary(selectedMovie)}
imdbId={selectedMovie.get("imdb_id")}
searching={selectedMovie.get("fetchingSubtitles", false)}
/>
</ShowMore> </ShowMore>
</ListDetails> </ListDetails>
</div> </div>

View File

@ -1,20 +1,24 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import { useDispatch, useSelector } from "react-redux";
import { List } from "immutable";
import { useDispatch } from "react-redux";
import { searchMovieSubtitles } from "../../actions/subtitles"; import { searchMovieSubtitles } from "../../actions/subtitles";
import { SubtitlesButton } from "../buttons/subtitles"; import { SubtitlesButton } from "../buttons/subtitles";
export const MovieSubtitlesButton = ({ export const MovieSubtitlesButton = () => {
inLibrary,
imdbId,
searching,
subtitles,
}) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const imdbId = useSelector((state) => state.movies.selectedImdbId);
const inLibrary = useSelector(
(state) => state.movies.movies.get(imdbId).polochon_url !== ""
);
const subtitles = useSelector(
(state) => state.movies.movies.get(imdbId).subtitles
);
const searching = useSelector(
(state) => state.movies.movies.get(imdbId).fetchingSubtitles
);
return ( return (
<SubtitlesButton <SubtitlesButton
inLibrary={inLibrary} inLibrary={inLibrary}
@ -24,9 +28,3 @@ export const MovieSubtitlesButton = ({
/> />
); );
}; };
MovieSubtitlesButton.propTypes = {
searching: PropTypes.bool,
inLibrary: PropTypes.bool,
imdbId: PropTypes.string,
subtitles: PropTypes.instanceOf(List),
};

View File

@ -1,13 +1,22 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import { useDispatch, useSelector } from "react-redux";
import { List } from "immutable";
import { useDispatch } from "react-redux";
import { getMovieDetails } from "../../actions/movies"; import { getMovieDetails } from "../../actions/movies";
import { TorrentsButton } from "../buttons/torrents"; import { TorrentsButton } from "../buttons/torrents";
export const MovieTorrentsButton = ({ torrents, imdbId, title, searching }) => { export const MovieTorrentsButton = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const imdbId = useSelector((state) => state.movies.selectedImdbId);
const title = useSelector((state) => state.movies.movies.get(imdbId).title);
const torrents = useSelector(
(state) => state.movies.movies.get(imdbId).torrents
);
const searching = useSelector(
(state) => state.movies.movies.get(imdbId).fetchingDetails
);
return ( return (
<TorrentsButton <TorrentsButton
torrents={torrents} torrents={torrents}
@ -17,10 +26,3 @@ export const MovieTorrentsButton = ({ torrents, imdbId, title, searching }) => {
/> />
); );
}; };
MovieTorrentsButton.propTypes = {
torrents: PropTypes.instanceOf(List),
imdbId: PropTypes.string,
title: PropTypes.string,
searching: PropTypes.bool,
getMovieDetails: PropTypes.func,
};

View File

@ -8,14 +8,11 @@ import Nav from "react-bootstrap/Nav";
import Navbar from "react-bootstrap/Navbar"; import Navbar from "react-bootstrap/Navbar";
import NavDropdown from "react-bootstrap/NavDropdown"; import NavDropdown from "react-bootstrap/NavDropdown";
const AppNavBar = () => { export const AppNavBar = () => {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const username = useSelector((state) => state.userStore.get("username")); const username = useSelector((state) => state.user.username);
const isAdmin = useSelector((state) => state.userStore.get("isAdmin")); const isAdmin = useSelector((state) => state.user.isAdmin);
const torrentCount = useSelector(
(state) => state.torrentStore.get("torrents").size
);
return ( return (
<Navbar <Navbar
@ -36,7 +33,7 @@ const AppNavBar = () => {
<MoviesDropdown /> <MoviesDropdown />
<ShowsDropdown /> <ShowsDropdown />
<WishlistDropdown /> <WishlistDropdown />
<TorrentsDropdown torrentsCount={torrentCount} /> <TorrentsDropdown />
</Nav> </Nav>
<Nav> <Nav>
<Route <Route
@ -70,7 +67,6 @@ const AppNavBar = () => {
AppNavBar.propTypes = { AppNavBar.propTypes = {
history: PropTypes.object, history: PropTypes.object,
}; };
export default AppNavBar;
const Search = ({ path, placeholder, setExpanded, history }) => { const Search = ({ path, placeholder, setExpanded, history }) => {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@ -159,8 +155,8 @@ const WishlistDropdown = () => (
</NavDropdown> </NavDropdown>
); );
const TorrentsDropdown = (props) => { const TorrentsDropdown = () => {
const title = <TorrentsDropdownTitle torrentsCount={props.torrentsCount} />; const title = <TorrentsDropdownTitle />;
return ( return (
<NavDropdown title={title} id="navbar-wishlit-dropdown"> <NavDropdown title={title} id="navbar-wishlit-dropdown">
<LinkContainer to="/torrents/list"> <LinkContainer to="/torrents/list">
@ -172,26 +168,17 @@ const TorrentsDropdown = (props) => {
</NavDropdown> </NavDropdown>
); );
}; };
TorrentsDropdown.propTypes = { torrentsCount: PropTypes.number.isRequired };
const TorrentsDropdownTitle = (props) => { const TorrentsDropdownTitle = () => {
if (props.torrentsCount === 0) { const count = useSelector((state) => state.torrents.torrents.length);
if (count === 0) {
return <span>Torrents</span>; return <span>Torrents</span>;
} }
return ( return (
<span> <span>
{" "}
Torrents Torrents
<span> <span className="badge badge-info badge-pill ml-1">{count}</span>
&nbsp;{" "}
<span className="badge badge-info badge-pill">
{props.torrentsCount}
</span>
</span>
</span> </span>
); );
}; };
TorrentsDropdownTitle.propTypes = {
torrentsCount: PropTypes.number.isRequired,
};

View File

@ -1,58 +1,44 @@
import React, { useState } from "react"; import React, { useState } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { useDispatch } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { Toast } from "react-bootstrap"; import { Toast } from "react-bootstrap";
import { removeNotification } from "../../actions/notifications"; import { removeNotification } from "../../actions/notifications";
export const Notification = ({ export const Notification = ({ id }) => {
id,
icon,
title,
message,
imageUrl,
autohide,
delay,
}) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const notification = useSelector((state) => state.notifications.get(id));
const [show, setShow] = useState(true); const [show, setShow] = useState(true);
const hide = () => { const hide = () => {
setShow(false); setShow(false);
setTimeout(() => dispatch(removeNotification(id)), 200); dispatch(removeNotification(id));
}; };
return ( return (
<Toast show={show} onClose={hide} autohide={autohide} delay={delay}> <Toast
show={show}
onClose={hide}
autohide={notification.autohide || true}
delay={notification.delay || 10000}
>
<Toast.Header> <Toast.Header>
{icon !== "" && <i className={`fa fa-${icon} mr-2`} />} {notification.icon && (
<strong className="mr-auto">{title}</strong> <i className={`fa fa-${notification.icon} mr-2`} />
)}
<strong className="mr-auto">{notification.title}</strong>
</Toast.Header> </Toast.Header>
<Toast.Body> <Toast.Body>
{message !== "" && <span>{message}</span>} {notification.message !== "" && <span>{notification.message}</span>}
{imageUrl !== "" && ( {notification.imageUrl !== "" && (
<img src={imageUrl} className="img-fluid mt-2 mr-auto" /> <img src={notification.imageUrl} className="img-fluid mt-2 mr-auto" />
)} )}
</Toast.Body> </Toast.Body>
</Toast> </Toast>
); );
}; };
Notification.propTypes = { Notification.propTypes = {
id: PropTypes.string, id: PropTypes.string.isRequired,
icon: PropTypes.string,
title: PropTypes.string,
message: PropTypes.string,
imageUrl: PropTypes.string,
autohide: PropTypes.bool,
delay: PropTypes.number,
};
Notification.defaultProps = {
autohide: false,
delay: 5000,
icon: "",
imageUrl: "",
title: "Info",
message: "",
}; };

View File

@ -1,29 +1,18 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types";
import { List } from "immutable";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { Notification } from "./notification"; import { Notification } from "./notification";
export const Notifications = () => { export const Notifications = () => {
const notifications = useSelector((state) => state.notifications); const notificationIds = useSelector((state) =>
Array.from(state.notifications.keys())
);
return ( return (
<div className="notifications"> <div className="notifications">
{notifications.map((el) => ( {notificationIds.map((id) => (
<Notification <Notification key={id} id={id} />
key={el.get("id")}
id={el.get("id")}
icon={el.get("icon", "video-camera")}
title={el.get("title", "Notification")}
message={el.get("message")}
autohide={el.get("autohide", true)}
delay={el.get("delay", 10000)}
imageUrl={el.get("imageUrl")}
/>
))} ))}
</div> </div>
); );
}; };
Notifications.propTypes = {
notifications: PropTypes.instanceOf(List),
};

View File

@ -7,7 +7,7 @@ import { Polochon } from "./polochon";
import { PolochonAdd } from "./add"; import { PolochonAdd } from "./add";
export const PolochonList = () => { export const PolochonList = () => {
const list = useSelector((state) => state.polochon.get("managed")); const list = useSelector((state) => state.polochon.managed);
const dispatch = useDispatch(); const dispatch = useDispatch();
useEffect(() => { useEffect(() => {
@ -20,15 +20,15 @@ export const PolochonList = () => {
<h2>My polochons</h2> <h2>My polochons</h2>
<hr /> <hr />
<span> <span>
{list.map((el, index) => ( {list.map((polochon, index) => (
<Polochon <Polochon
key={index} key={index}
id={el.get("id")} id={polochon.id}
name={el.get("name")} name={polochon.name}
token={el.get("token")} token={polochon.token}
url={el.get("url")} url={polochon.url}
authToken={el.get("auth_token")} authToken={polochon.auth_token}
users={el.get("users")} users={polochon.users}
/> />
))} ))}
</span> </span>

View File

@ -1,6 +1,5 @@
import React, { useState } from "react"; import React, { useState } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { List } from "immutable";
import { useDispatch } from "react-redux"; import { useDispatch } from "react-redux";
import { PolochonUsers } from "./users"; import { PolochonUsers } from "./users";
@ -57,5 +56,5 @@ Polochon.propTypes = {
token: PropTypes.string, token: PropTypes.string,
url: PropTypes.string, url: PropTypes.string,
authToken: PropTypes.string, authToken: PropTypes.string,
users: PropTypes.instanceOf(List), users: PropTypes.array.isRequired,
}; };

View File

@ -1,8 +1,10 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { List } from "immutable"; import { useSelector } from "react-redux";
export const PolochonSelect = ({ value, changeValue }) => {
const publicPolochons = useSelector((state) => state.polochon.public);
export const PolochonSelect = ({ value, changeValue, polochonList }) => {
return ( return (
<select <select
className="form-control" className="form-control"
@ -11,9 +13,9 @@ export const PolochonSelect = ({ value, changeValue, polochonList }) => {
changeValue(e.target.options[e.target.selectedIndex].value) changeValue(e.target.options[e.target.selectedIndex].value)
} }
> >
{polochonList.map((el, index) => ( {publicPolochons.map((polochon) => (
<option value={el.get("id")} key={index}> <option value={polochon.id} key={polochon.id}>
{el.get("name")} ({el.get("url")}) {polochon.name} ({polochon.url})
</option> </option>
))} ))}
</select> </select>
@ -21,6 +23,5 @@ export const PolochonSelect = ({ value, changeValue, polochonList }) => {
}; };
PolochonSelect.propTypes = { PolochonSelect.propTypes = {
value: PropTypes.string, value: PropTypes.string,
changeValue: PropTypes.func, changeValue: PropTypes.func.isRequired,
polochonList: PropTypes.instanceOf(List),
}; };

View File

@ -1,6 +1,5 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { List } from "immutable";
import { PolochonUser } from "./user"; import { PolochonUser } from "./user";
@ -23,10 +22,10 @@ export const PolochonUsers = ({ id, users }) => {
<PolochonUser <PolochonUser
key={index} key={index}
polochonId={id} polochonId={id}
id={user.get("id")} id={user.id}
name={user.get("name")} name={user.name}
initialToken={user.get("token")} initialToken={user.token}
initialActivated={user.get("polochon_activated")} initialActivated={user.polochon_activated}
/> />
))} ))}
</tbody> </tbody>
@ -34,6 +33,6 @@ export const PolochonUsers = ({ id, users }) => {
); );
}; };
PolochonUsers.propTypes = { PolochonUsers.propTypes = {
id: PropTypes.string, id: PropTypes.string.isRequired,
users: PropTypes.instanceOf(List), users: PropTypes.array.isRequired,
}; };

View File

@ -12,8 +12,7 @@ import { fetchShowDetails } from "../../actions/shows";
export const ShowDetails = ({ match }) => { export const ShowDetails = ({ match }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const loading = useSelector((state) => state.showStore.get("loading")); const loading = useSelector((state) => state.show.loading);
const show = useSelector((state) => state.showStore.get("show"));
useEffect(() => { useEffect(() => {
dispatch(fetchShowDetails(match.params.imdbId)); dispatch(fetchShowDetails(match.params.imdbId));
@ -25,10 +24,10 @@ export const ShowDetails = ({ match }) => {
return ( return (
<React.Fragment> <React.Fragment>
<Fanart url={show.get("fanart_url")} /> <Fanart />
<div className="row no-gutters"> <div className="row no-gutters">
<Header data={show} /> <Header />
<SeasonsList data={show} /> <SeasonsList />
</div> </div>
</React.Fragment> </React.Fragment>
); );

View File

@ -1,15 +1,10 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { Map } from "immutable";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { showWishlistToggle } from "../../../actions/shows"; import { showWishlistToggle } from "../../../actions/shows";
import { import { prettyEpisodeName } from "../../../utils";
inLibrary,
isEpisodeWishlisted,
prettyEpisodeName,
} from "../../../utils";
import { Plot } from "../../details/plot"; import { Plot } from "../../details/plot";
import { PolochonMetadata } from "../../details/polochon"; import { PolochonMetadata } from "../../details/polochon";
@ -24,88 +19,75 @@ import { EpisodeSubtitlesButton } from "./subtitlesButton";
import { EpisodeThumb } from "./episodeThumb"; import { EpisodeThumb } from "./episodeThumb";
import { EpisodeTorrentsButton } from "./torrentsButton"; import { EpisodeTorrentsButton } from "./torrentsButton";
export const Episode = (props) => { export const Episode = ({ season, episode }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const trackedSeason = useSelector((state) => const imdbId = useSelector((state) => state.show.show.imdb_id);
state.showStore.getIn(["show", "tracked_season"], null) const showTitle = useSelector((state) => state.show.show.title);
);
const trackedEpisode = useSelector((state) => const data = useSelector((state) =>
state.showStore.getIn(["show", "tracked_episode"], null) state.show.show.seasons.get(season).get(episode)
); );
const isWishlisted = useSelector((state) => {
const trackedSeason = state.show.show.tracked_season;
const trackedEpisode = state.show.show.tracked_episode;
if (trackedSeason == null || trackedEpisode == null) {
return false;
}
if (trackedSeason == 0 || trackedEpisode == 0) {
return true;
}
if (season < trackedSeason) {
return false;
} else if (season > trackedSeason) {
return true;
} else {
return episode >= trackedEpisode;
}
});
return ( return (
<div className="d-flex flex-column flex-lg-row mb-3 pb-3 border-bottom border-light"> <div className="d-flex flex-column flex-lg-row mb-3 pb-3 border-bottom border-light">
<EpisodeThumb url={props.data.get("thumb")} /> <EpisodeThumb season={season} episode={episode} />
<div className="d-flex flex-column"> <div className="d-flex flex-column">
<Title <Title
title={`${props.data.get("episode")}. ${props.data.get("title")}`} title={`${episode}. ${data.title}`}
wishlisted={isEpisodeWishlisted( wishlisted={isWishlisted}
props.data,
trackedSeason,
trackedEpisode
)}
wishlist={() => wishlist={() =>
dispatch( dispatch(showWishlistToggle(isWishlisted, imdbId, season, episode))
showWishlistToggle(
isEpisodeWishlisted(props.data),
props.data.get("show_imdb_id"),
props.data.get("season"),
props.data.get("episode")
)
)
} }
/> />
<ReleaseDate date={props.data.get("aired")} /> <ReleaseDate date={data.aired} />
<Runtime runtime={props.data.get("runtime")} /> <Runtime runtime={data.runtime} />
<Plot plot={props.data.get("plot")} /> <Plot plot={data.plot} />
<DownloadAndStream <DownloadAndStream
name={prettyEpisodeName( name={prettyEpisodeName(showTitle, season, episode)}
props.showName, url={data.polochon_url}
props.data.get("season"), subtitles={data.subtitles}
props.data.get("episode")
)}
url={props.data.get("polochon_url")}
subtitles={props.data.get("subtitles")}
/> />
<PolochonMetadata <PolochonMetadata
quality={props.data.get("quality")} quality={data.quality}
releaseGroup={props.data.get("release_group")} releaseGroup={data.release_group}
container={props.data.get("container")} container={data.container}
audioCodec={props.data.get("audio_codec")} audioCodec={data.audio_codec}
videoCodec={props.data.get("video_codec")} videoCodec={data.video_codec}
light light
/> />
<ShowMore <ShowMore
id={prettyEpisodeName( id={prettyEpisodeName(showTitle, season, episode)}
props.showName, inLibrary={data.polochon_url !== ""}
props.data.get("season"),
props.data.get("episode")
)}
inLibrary={inLibrary(props.data)}
> >
<EpisodeTorrentsButton <EpisodeTorrentsButton season={season} episode={episode} />
torrents={props.data.get("torrents")} <EpisodeSubtitlesButton season={season} episode={episode} />
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> </ShowMore>
</div> </div>
</div> </div>
); );
}; };
Episode.propTypes = { Episode.propTypes = {
data: PropTypes.instanceOf(Map).isRequired, season: PropTypes.number.isRequired,
showName: PropTypes.string.isRequired, episode: PropTypes.number.isRequired,
}; };

View File

@ -1,15 +1,23 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { useSelector } from "react-redux";
export const EpisodeThumb = ({ season, episode }) => {
const url = useSelector(
(state) => state.show.show.seasons.get(season).get(episode).thumb
);
export const EpisodeThumb = ({ url }) => {
if (url === "") { if (url === "") {
return null; return null;
} }
return ( return (
<div className="mr-0 mr-lg-2 mb-2 mb-lg-0 episode-thumb"> <div className="mr-0 mr-lg-2 mb-2 mb-lg-0 episode-thumb">
<img src={url} /> <img src={url} />
</div> </div>
); );
}; };
EpisodeThumb.propTypes = { url: PropTypes.string }; EpisodeThumb.propTypes = {
EpisodeThumb.defaultProps = { url: "" }; season: PropTypes.number.isRequired,
episode: PropTypes.number.isRequired,
};

View File

@ -1,7 +1,9 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import { useSelector } from "react-redux";
export const Fanart = ({ url }) => ( export const Fanart = () => {
const url = useSelector((state) => state.show.show.fanart_url);
return (
<div className="show-fanart mx-n3 mt-n1"> <div className="show-fanart mx-n3 mt-n1">
<img <img
className="img w-100 object-fit-cover object-position-top mh-40vh" className="img w-100 object-fit-cover object-position-top mh-40vh"
@ -9,6 +11,4 @@ export const Fanart = ({ url }) => (
/> />
</div> </div>
); );
Fanart.propTypes = {
url: PropTypes.string,
}; };

View File

@ -1,11 +1,5 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import { useDispatch, useSelector } from "react-redux";
import { Map } from "immutable";
import { useDispatch } from "react-redux";
import { isWishlisted } from "../../../utils";
import { showWishlistToggle } from "../../../actions/shows";
import { Plot } from "../../details/plot"; import { Plot } from "../../details/plot";
import { Rating } from "../../details/rating"; import { Rating } from "../../details/rating";
@ -15,51 +9,62 @@ import { TrackingLabel } from "../../details/tracking";
import { ImdbBadge } from "../../buttons/imdb"; import { ImdbBadge } from "../../buttons/imdb";
export const Header = (props) => { import { showWishlistToggle } from "../../../actions/shows";
export const Header = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const posterUrl = useSelector((state) => state.show.show.poster_url);
const title = useSelector((state) => state.show.show.title);
const imdbId = useSelector((state) => state.show.show.imdb_id);
const year = useSelector((state) => state.show.show.year);
const rating = useSelector((state) => state.show.show.rating);
const plot = useSelector((state) => state.show.show.plot);
const trackedSeason = useSelector((state) => state.show.show.tracked_season);
const trackedEpisode = useSelector(
(state) => state.show.show.tracked_episode
);
const wishlisted = useSelector(
(state) =>
state.show.show.tracked_season !== null &&
state.show.show.tracked_episode !== null
);
return ( return (
<div className="card col-12 col-md-10 offset-md-1 mt-n3 mb-3"> <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 flex-column flex-md-row">
<div className="d-flex justify-content-center"> <div className="d-flex justify-content-center">
<img <img className="overflow-hidden object-fit-cover" src={posterUrl} />
className="overflow-hidden object-fit-cover"
src={props.data.get("poster_url")}
/>
</div> </div>
<div> <div>
<div className="card-body"> <div className="card-body">
<p className="card-title"> <p className="card-title">
<Title <Title
title={props.data.get("title")} title={title}
wishlisted={isWishlisted(props.data)} wishlisted={wishlisted}
wishlist={() => wishlist={() =>
dispatch( dispatch(showWishlistToggle(wishlisted, imdbId))
showWishlistToggle(
isWishlisted(props.data),
props.data.get("imdb_id")
)
)
} }
/> />
</p> </p>
<p className="card-text"> <p className="card-text">
<ReleaseDate date={props.data.get("year")} /> <ReleaseDate date={year} />
</p> </p>
<p className="card-text"> <p className="card-text">
<Rating rating={props.data.get("rating")} /> <Rating rating={rating} />
</p> </p>
<p className="card-text"> <p className="card-text">
<ImdbBadge imdbId={props.data.get("imdb_id")} /> <ImdbBadge imdbId={imdbId} />
</p> </p>
<p className="card-text"> <p className="card-text">
<TrackingLabel <TrackingLabel
inLibrary={false} inLibrary={false}
trackedSeason={props.data.get("tracked_season")} trackedSeason={trackedSeason}
trackedEpisode={props.data.get("tracked_episode")} trackedEpisode={trackedEpisode}
/> />
</p> </p>
<p className="card-text"> <p className="card-text">
<Plot plot={props.data.get("plot")} /> <Plot plot={plot} />
</p> </p>
</div> </div>
</div> </div>
@ -67,6 +72,3 @@ export const Header = (props) => {
</div> </div>
); );
}; };
Header.propTypes = {
data: PropTypes.instanceOf(Map),
};

View File

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

View File

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

View File

@ -1,37 +1,45 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { List } from "immutable"; import { useDispatch, useSelector } from "react-redux";
import { useDispatch } from "react-redux";
import { searchEpisodeSubtitles } from "../../../actions/subtitles"; import { searchEpisodeSubtitles } from "../../../actions/subtitles";
import { SubtitlesButton } from "../../buttons/subtitles"; import { SubtitlesButton } from "../../buttons/subtitles";
export const EpisodeSubtitlesButton = ({ export const EpisodeSubtitlesButton = ({ season, episode }) => {
inLibrary,
imdbId,
season,
episode,
searching,
subtitles,
}) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const imdbId = useSelector((state) => state.show.show.imdb_id);
const searching = useSelector((state) =>
state.show.show.seasons.get(season).get(episode).fetchingSubtitles
? state.show.show.seasons.get(season).get(episode).fetchingSubtitles
: false
);
const inLibrary = useSelector(
(state) =>
state.show.show.seasons.get(season).get(episode).polochon_url !== ""
);
const subtitles = useSelector((state) =>
state.show.show.seasons.get(season).get(episode).subtitles
? state.show.show.seasons.get(season).get(episode).subtitles
: []
);
const search = () => {
dispatch(searchEpisodeSubtitles(imdbId, season, episode));
};
return ( return (
<SubtitlesButton <SubtitlesButton
subtitles={subtitles} subtitles={subtitles}
inLibrary={inLibrary} inLibrary={inLibrary}
searching={searching} searching={searching}
search={() => dispatch(searchEpisodeSubtitles(imdbId, season, episode))} search={search}
/> />
); );
}; };
EpisodeSubtitlesButton.propTypes = { EpisodeSubtitlesButton.propTypes = {
inLibrary: PropTypes.bool, season: PropTypes.number.isRequired,
searching: PropTypes.bool, episode: PropTypes.number.isRequired,
imdbId: PropTypes.string,
season: PropTypes.number,
episode: PropTypes.number,
subtitles: PropTypes.instanceOf(List),
}; };

View File

@ -1,39 +1,46 @@
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { useDispatch } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { List } from "immutable";
import { getEpisodeDetails } from "../../../actions/shows"; import { getEpisodeDetails } from "../../../actions/shows";
import { TorrentsButton } from "../../buttons/torrents"; import { TorrentsButton } from "../../buttons/torrents";
import { prettyEpisodeName } from "../../../utils"; import { prettyEpisodeName } from "../../../utils";
export const EpisodeTorrentsButton = ({ export const EpisodeTorrentsButton = ({ season, episode }) => {
torrents,
imdbId,
season,
episode,
showName,
searching,
}) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const imdbId = useSelector((state) => state.show.show.imdb_id);
const search = () => {
dispatch(getEpisodeDetails(imdbId, season, episode));
};
const searching = useSelector((state) =>
state.show.show.seasons.get(season).get(episode).fetching
? state.show.show.seasons.get(season).get(episode).fetching
: false
);
const torrents = useSelector((state) =>
state.show.show.seasons.get(season).get(episode).torrents
? state.show.show.seasons.get(season).get(episode).torrents
: []
);
const url = useSelector(
(state) =>
"#/torrents/search/shows/" +
encodeURI(prettyEpisodeName(state.show.show.title, season, episode))
);
return ( return (
<TorrentsButton <TorrentsButton
torrents={torrents} torrents={torrents}
searching={searching} searching={searching}
search={() => dispatch(getEpisodeDetails(imdbId, season, episode))} search={search}
url={`#/torrents/search/shows/${encodeURI( url={url}
prettyEpisodeName(showName, season, episode)
)}`}
/> />
); );
}; };
EpisodeTorrentsButton.propTypes = { EpisodeTorrentsButton.propTypes = {
torrents: PropTypes.instanceOf(List),
showName: PropTypes.string.isRequired,
imdbId: PropTypes.string.isRequired,
episode: PropTypes.number.isRequired, episode: PropTypes.number.isRequired,
season: PropTypes.number.isRequired, season: PropTypes.number.isRequired,
searching: PropTypes.bool.isRequired,
}; };

View File

@ -1,18 +1,14 @@
import React, { useEffect, useCallback } from "react"; import React, { useEffect, useCallback } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { Map } from "immutable";
import { useSelector, useDispatch } from "react-redux"; import { useSelector, useDispatch } from "react-redux";
import { import {
fetchShows, fetchShows,
selectShow, selectShow,
showWishlistToggle, showWishlistToggle,
updateFilter,
getShowExploreOptions, getShowExploreOptions,
} from "../../actions/shows"; } from "../../actions/shows";
import { isWishlisted } from "../../utils";
import ListDetails from "../list/details"; import ListDetails from "../list/details";
import ListPosters from "../list/posters"; import ListPosters from "../list/posters";
@ -46,34 +42,30 @@ const ShowList = ({ match, history }) => {
} }
}, [dispatch, match]); }, [dispatch, match]);
const loading = useSelector((state) => state.showsStore.get("loading")); const loading = useSelector((state) => state.shows.loading);
const shows = useSelector((state) => state.showsStore.get("shows")); const shows = useSelector((state) => state.shows.shows);
const filter = useSelector((state) => state.showsStore.get("filter")); const selectedImdbId = useSelector((state) => state.shows.selectedImdbId);
const selectedImdbId = useSelector((state) => const exploreOptions = useSelector((state) => state.shows.exploreOptions);
state.showsStore.get("selectedImdbId")
);
const exploreOptions = useSelector((state) =>
state.showsStore.get("exploreOptions")
);
const showDetails = (imdbId) => { const showDetails = (imdbId) => {
history.push("/shows/details/" + imdbId); history.push("/shows/details/" + imdbId);
}; };
let selectedShow = Map(); let selectedShow = new Map();
if (selectedImdbId !== "") { if (selectedImdbId !== "") {
selectedShow = shows.get(selectedImdbId); selectedShow = shows.get(selectedImdbId);
} }
const selectFunc = useCallback((id) => dispatch(selectShow(id)), [dispatch]); const selectFunc = useCallback((id) => dispatch(selectShow(id)), [dispatch]);
const filterFunc = useCallback((filter) => dispatch(updateFilter(filter)), [
dispatch,
]);
const exploreFetchOptions = useCallback( const exploreFetchOptions = useCallback(
() => dispatch(getShowExploreOptions()), () => dispatch(getShowExploreOptions()),
[dispatch] [dispatch]
); );
const wishlisted =
selectedShow.tracked_season !== null &&
selectedShow.tracked_episode !== null;
return ( return (
<div className="row" id="container"> <div className="row" id="container">
<ListPosters <ListPosters
@ -82,9 +74,7 @@ const ShowList = ({ match, history }) => {
placeHolder="Filter shows..." placeHolder="Filter shows..."
exploreOptions={exploreOptions} exploreOptions={exploreOptions}
exploreFetchOptions={exploreFetchOptions} exploreFetchOptions={exploreFetchOptions}
updateFilter={filterFunc}
selectedImdbId={selectedImdbId} selectedImdbId={selectedImdbId}
filter={filter}
onClick={selectFunc} onClick={selectFunc}
onDoubleClick={showDetails} onDoubleClick={showDetails}
onKeyEnter={showDetails} onKeyEnter={showDetails}
@ -94,18 +84,14 @@ const ShowList = ({ match, history }) => {
<ListDetails <ListDetails
data={selectedShow} data={selectedShow}
loading={loading} loading={loading}
wishlisted={wishlisted}
wishlist={() => wishlist={() =>
dispatch( dispatch(showWishlistToggle(wishlisted, selectedShow.imdb_id))
showWishlistToggle(
isWishlisted(selectedShow),
selectedShow.get("imdb_id")
)
)
} }
> >
<span> <span>
<button <button
onClick={() => showDetails(selectedShow.get("imdb_id"))} onClick={() => showDetails(selectedShow.imdb_id)}
className="btn btn-primary btn-sm w-md-100" className="btn btn-primary btn-sm w-md-100"
> >
<i className="fa fa-external-link mr-1" /> <i className="fa fa-external-link mr-1" />

View File

@ -1,30 +1,23 @@
import React, { useState } from "react"; import React, { useState } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { Map, List } from "immutable";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { prettySize } from "../../utils"; import { prettySize } from "../../utils";
import { addTorrent, removeTorrent } from "../../actions/torrents"; import { addTorrent, removeTorrent } from "../../actions/torrents";
const TorrentList = () => { export const TorrentList = () => {
const torrents = useSelector((state) => state.torrentStore.get("torrents"));
const dispatch = useDispatch();
return ( return (
<div className="row"> <div className="row">
<div className="col-12"> <div className="col-12">
<AddTorrent addTorrent={(url) => dispatch(addTorrent(url))} /> <AddTorrent />
<Torrents <Torrents />
torrents={torrents}
removeTorrent={(id) => dispatch(removeTorrent(id))}
/>
</div> </div>
</div> </div>
); );
}; };
export default TorrentList;
const AddTorrent = (props) => { const AddTorrent = () => {
const dispatch = useDispatch();
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const handleSubmit = (e) => { const handleSubmit = (e) => {
@ -32,7 +25,7 @@ const AddTorrent = (props) => {
if (url === "") { if (url === "") {
return; return;
} }
props.addTorrent(url); dispatch(addTorrent(url));
setUrl(""); setUrl("");
}; };
@ -49,12 +42,11 @@ const AddTorrent = (props) => {
</form> </form>
); );
}; };
AddTorrent.propTypes = {
addTorrent: PropTypes.func.isRequired,
};
const Torrents = (props) => { const Torrents = () => {
if (props.torrents.size === 0) { const torrents = useSelector((state) => state.torrents.torrents);
if (torrents.length === 0) {
return ( return (
<div className="jumbotron"> <div className="jumbotron">
<h2>No torrents</h2> <h2>No torrents</h2>
@ -64,45 +56,38 @@ const Torrents = (props) => {
return ( return (
<div className="d-flex flex-wrap"> <div className="d-flex flex-wrap">
{props.torrents.map((el, index) => ( {torrents.map((torrent, index) => (
<Torrent key={index} data={el} removeTorrent={props.removeTorrent} /> <Torrent key={index} torrent={torrent} />
))} ))}
</div> </div>
); );
}; };
Torrents.propTypes = {
removeTorrent: PropTypes.func.isRequired,
torrents: PropTypes.instanceOf(List),
};
const Torrent = (props) => { const Torrent = ({ torrent }) => {
const handleClick = () => { const dispatch = useDispatch();
props.removeTorrent(props.data.get("id"));
};
const done = props.data.get("is_finished"); var progressStyle = torrent.is_finished
var progressStyle = done
? "success" ? "success"
: "info progress-bar-striped progress-bar-animated"; : "info progress-bar-striped progress-bar-animated";
const progressBarClass = "progress-bar bg-" + progressStyle; const progressBarClass = "progress-bar bg-" + progressStyle;
var percentDone = props.data.get("percent_done"); var percentDone = torrent.percent_done;
const started = percentDone !== 0; const started = percentDone !== 0;
if (started) { if (started) {
percentDone = Number(percentDone).toFixed(1) + "%"; percentDone = Number(percentDone).toFixed(1) + "%";
} }
// Pretty sizes // Pretty sizes
const downloadedSize = prettySize(props.data.get("downloaded_size")); const downloadedSize = prettySize(torrent.downloaded_size);
const totalSize = prettySize(props.data.get("total_size")); const totalSize = prettySize(torrent.total_size);
const downloadRate = prettySize(props.data.get("download_rate")) + "/s"; const downloadRate = prettySize(torrent.download_rate) + "/s";
return ( return (
<div className="card w-100 mb-3"> <div className="card w-100 mb-3">
<h5 className="card-header"> <h5 className="card-header">
<span className="text text-break">{props.data.get("name")}</span> <span className="text text-break">{torrent.name}</span>
<span <span
className="fa fa-trash clickable pull-right" className="fa fa-trash clickable pull-right"
onClick={() => handleClick()} onClick={() => dispatch(removeTorrent(torrent.id))}
></span> ></span>
</h5> </h5>
<div className="card-body pb-0"> <div className="card-body pb-0">
@ -129,6 +114,5 @@ const Torrent = (props) => {
); );
}; };
Torrent.propTypes = { Torrent.propTypes = {
removeTorrent: PropTypes.func.isRequired, torrent: PropTypes.object.isRequired,
data: PropTypes.instanceOf(Map),
}; };

View File

@ -2,21 +2,15 @@ import React, { useState, useEffect, useCallback } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { addTorrent, searchTorrents } from "../../actions/torrents"; import { addTorrent, searchTorrents } from "../../actions/torrents";
import { Map, List } from "immutable";
import Loader from "../loader/loader"; import Loader from "../loader/loader";
import { OverlayTrigger, Tooltip } from "react-bootstrap"; import { OverlayTrigger, Tooltip } from "react-bootstrap";
import { prettySize } from "../../utils"; import { prettySize } from "../../utils";
const TorrentSearch = ({ history, match }) => { export const TorrentSearch = ({ history, match }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const searching = useSelector((state) => state.torrentStore.get("searching"));
const results = useSelector((state) =>
state.torrentStore.get("searchResults")
);
const [search, setSearch] = useState(match.params.search || ""); const [search, setSearch] = useState(match.params.search || "");
const [type, setType] = useState(match.params.type || ""); const [type, setType] = useState(match.params.type || "");
@ -74,18 +68,13 @@ const TorrentSearch = ({ history, match }) => {
</div> </div>
</div> </div>
<div className="col-12"> <div className="col-12">
<TorrentList <TorrentList search={search} />
searching={searching}
results={results}
addTorrent={(url) => dispatch(addTorrent(url))}
searchFromURL={search}
/>
</div> </div>
</div> </div>
); );
}; };
TorrentSearch.propTypes = { TorrentSearch.propTypes = {
searchFromURL: PropTypes.string, search: PropTypes.string,
match: PropTypes.object, match: PropTypes.object,
history: PropTypes.object, history: PropTypes.object,
}; };
@ -109,16 +98,19 @@ SearchButton.propTypes = {
handleClick: PropTypes.func.isRequired, handleClick: PropTypes.func.isRequired,
}; };
const TorrentList = (props) => { const TorrentList = ({ search }) => {
if (props.searching) { const searching = useSelector((state) => state.torrents.searching);
const results = useSelector((state) => state.torrents.searchResults);
if (searching) {
return <Loader />; return <Loader />;
} }
if (props.searchFromURL === "") { if (search === "") {
return null; return null;
} }
if (props.results.size === 0) { if (results.size === 0) {
return ( return (
<div className="jumbotron"> <div className="jumbotron">
<h2>No results</h2> <h2>No results</h2>
@ -128,63 +120,60 @@ const TorrentList = (props) => {
return ( return (
<React.Fragment> <React.Fragment>
{props.results.map(function (el, index) { {results.map(function (el, index) {
return <Torrent key={index} data={el} addTorrent={props.addTorrent} />; return <Torrent key={index} torrent={el} />;
})} })}
</React.Fragment> </React.Fragment>
); );
}; };
TorrentList.propTypes = { TorrentList.propTypes = {
searching: PropTypes.bool.isRequired, search: PropTypes.string,
results: PropTypes.instanceOf(List),
searchFromURL: PropTypes.string,
addTorrent: PropTypes.func.isRequired,
}; };
const Torrent = (props) => ( const Torrent = ({ torrent }) => {
const dispatch = useDispatch();
return (
<div className="alert d-flex border-bottom border-secondary align-items-center"> <div className="alert d-flex border-bottom border-secondary align-items-center">
<TorrentHealth <TorrentHealth
url={props.data.get("url")} url={torrent.url}
seeders={props.data.get("seeders")} seeders={torrent.seeders}
leechers={props.data.get("leechers")} leechers={torrent.leechers}
/> />
<span className="mx-3 text text-start text-break flex-fill"> <span className="mx-3 text text-start text-break flex-fill">
{props.data.get("name")} {torrent.name}
</span> </span>
<div> <div>
{props.data.get("size") !== 0 && ( {torrent.size !== 0 && (
<span className="mx-1 badge badge-pill badge-warning"> <span className="mx-1 badge badge-pill badge-warning">
{prettySize(props.data.get("size"))} {prettySize(torrent.size)}
</span> </span>
)} )}
<span className="mx-1 badge badge-pill badge-warning"> <span className="mx-1 badge badge-pill badge-warning">
{props.data.get("quality")} {torrent.quality}
</span> </span>
<span className="mx-1 badge badge-pill badge-success"> <span className="mx-1 badge badge-pill badge-success">
{props.data.get("source")} {torrent.source}
</span> </span>
<span className="mx-1 badge badge-pill badge-info"> <span className="mx-1 badge badge-pill badge-info">
{props.data.get("upload_user")} {torrent.upload_user}
</span> </span>
</div> </div>
<div className="align-self-end ml-3"> <div className="align-self-end ml-3">
<i <i
className="fa fa-cloud-download clickable" className="fa fa-cloud-download clickable"
onClick={() => props.addTorrent(props.data.get("url"))} onClick={() => dispatch(addTorrent(torrent.url))}
></i> ></i>
</div> </div>
</div> </div>
); );
};
Torrent.propTypes = { Torrent.propTypes = {
data: PropTypes.instanceOf(Map), torrent: PropTypes.object.isRequired,
addTorrent: PropTypes.func.isRequired,
}; };
const TorrentHealth = (props) => { const TorrentHealth = ({ url, seeders = 0, leechers = 1 }) => {
const seeders = props.seeders || 0;
const leechers = props.leechers || 1;
let color; let color;
let health; let health;
let ratio = seeders / leechers; let ratio = seeders / leechers;
@ -204,12 +193,12 @@ const TorrentHealth = (props) => {
const className = `align-self-start text text-center text-${color}`; const className = `align-self-start text text-center text-${color}`;
const tooltip = ( const tooltip = (
<Tooltip id={`tooltip-health-${props.url}`}> <Tooltip id={`tooltip-health-${url}`}>
<p> <p>
<span className={className}>Health: {health}</span> <span className={className}>Health: {health}</span>
</p> </p>
<p>Seeders: {seeders}</p> <p>Seeders: {seeders}</p>
<p>Leechers: {props.leechers}</p> <p>Leechers: {leechers}</p>
</Tooltip> </Tooltip>
); );
@ -226,5 +215,3 @@ TorrentHealth.propTypes = {
seeders: PropTypes.number, seeders: PropTypes.number,
leechers: PropTypes.number, leechers: PropTypes.number,
}; };
export default TorrentSearch;

View File

@ -2,11 +2,9 @@ import React from "react";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
import { Redirect, Link } from "react-router-dom"; import { Redirect, Link } from "react-router-dom";
const UserActivation = () => { export const UserActivation = () => {
const isLogged = useSelector((state) => state.userStore.get("isLogged")); const isLogged = useSelector((state) => state.user.isLogged);
const isActivated = useSelector((state) => const isActivated = useSelector((state) => state.user.isActivated);
state.userStore.get("isActivated")
);
if (!isLogged) { if (!isLogged) {
return <Redirect to="/users/login" />; return <Redirect to="/users/login" />;
@ -30,4 +28,3 @@ const UserActivation = () => {
</div> </div>
); );
}; };
export default UserActivation;

View File

@ -14,11 +14,10 @@ export const UserEdit = () => {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = useState(""); const [passwordConfirm, setPasswordConfirm] = useState("");
const loading = useSelector((state) => state.userStore.get("loading")); const loading = useSelector((state) => state.user.loading);
const publicPolochons = useSelector((state) => state.polochon.get("public")); const polochonId = useSelector((state) => state.user.polochonId);
const polochonId = useSelector((state) => state.userStore.get("polochonId")); const polochonActivated = useSelector(
const polochonActivated = useSelector((state) => (state) => state.user.polochonActivated
state.userStore.get("polochonActivated")
); );
useEffect(() => { useEffect(() => {
@ -60,11 +59,7 @@ export const UserEdit = () => {
</span> </span>
)} )}
</label> </label>
<PolochonSelect <PolochonSelect value={id} changeValue={setId} />
value={id}
changeValue={setId}
polochonList={publicPolochons}
/>
</div> </div>
<hr /> <hr />

View File

@ -4,12 +4,12 @@ import { Redirect, Link } from "react-router-dom";
import { loginUser } from "../../actions/users"; import { loginUser } from "../../actions/users";
const UserLoginForm = () => { export const UserLoginForm = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const isLogged = useSelector((state) => state.userStore.get("isLogged")); const isLogged = useSelector((state) => state.user.isLogged);
const isLoading = useSelector((state) => state.userStore.get("loading")); const isLoading = useSelector((state) => state.user.loading);
const error = useSelector((state) => state.userStore.get("error")); const error = useSelector((state) => state.user.error);
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@ -85,5 +85,3 @@ const UserLoginForm = () => {
</div> </div>
); );
}; };
export default UserLoginForm;

View File

@ -4,15 +4,14 @@ import { Redirect } from "react-router-dom";
import { userLogout } from "../../actions/users"; import { userLogout } from "../../actions/users";
const UserLogout = () => { export const UserLogout = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const isLogged = useSelector((state) => state.userStore.get("isLogged")); const isLogged = useSelector((state) => state.user.isLogged);
if (isLogged) { if (isLogged) {
dispatch(userLogout()); dispatch(userLogout());
} return null;
} else {
return <Redirect to="/users/login" />; return <Redirect to="/users/login" />;
}
}; };
export default UserLogout;

View File

@ -7,12 +7,10 @@ import { UserEdit } from "./edit";
import { getUserModules } from "../../actions/users"; import { getUserModules } from "../../actions/users";
import Modules from "../modules/modules"; import Modules from "../modules/modules";
const UserProfile = () => { export const UserProfile = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const modules = useSelector((state) => state.userStore.get("modules")); const modules = useSelector((state) => state.user.modules);
const modulesLoading = useSelector((state) => const modulesLoading = useSelector((state) => state.user.modulesLoading);
state.userStore.get("modulesLoading")
);
useEffect(() => { useEffect(() => {
dispatch(getUserModules()); dispatch(getUserModules());
@ -26,5 +24,3 @@ const UserProfile = () => {
</div> </div>
); );
}; };
export default UserProfile;

View File

@ -4,16 +4,16 @@ import { Redirect } from "react-router-dom";
import { userSignUp } from "../../actions/users"; import { userSignUp } from "../../actions/users";
const UserSignUp = () => { export const UserSignUp = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = useState(""); const [passwordConfirm, setPasswordConfirm] = useState("");
const isLogged = useSelector((state) => state.userStore.get("isLogged")); const isLogged = useSelector((state) => state.user.isLogged);
const isLoading = useSelector((state) => state.userStore.get("loading")); const isLoading = useSelector((state) => state.user.loading);
const error = useSelector((state) => state.userStore.get("error")); const error = useSelector((state) => state.user.error);
if (isLogged) { if (isLogged) {
return <Redirect to="/" />; return <Redirect to="/" />;
@ -90,5 +90,3 @@ const UserSignUp = () => {
</div> </div>
); );
}; };
export default UserSignUp;

View File

@ -3,14 +3,12 @@ import PropTypes from "prop-types";
import { useDispatch, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { UAParser } from "ua-parser-js"; import { UAParser } from "ua-parser-js";
import moment from "moment"; import moment from "moment";
import { Map } from "immutable";
import { getUserTokens, deleteUserToken } from "../../actions/users"; import { getUserTokens, deleteUserToken } from "../../actions/users";
const UserTokens = () => { export const UserTokens = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const tokens = useSelector((state) => state.user.tokens);
const tokens = useSelector((state) => state.userStore.get("tokens"));
useEffect(() => { useEffect(() => {
dispatch(getUserTokens()); dispatch(getUserTokens());
@ -19,34 +17,30 @@ const UserTokens = () => {
return ( return (
<div className="row"> <div className="row">
<div className="col-12"> <div className="col-12">
{tokens.map((el, index) => ( {tokens.map((token, index) => (
<Token <Token key={index} token={token} />
key={index}
data={el}
deleteToken={(token) => dispatch(deleteUserToken(token))}
/>
))} ))}
</div> </div>
</div> </div>
); );
}; };
const Token = (props) => { const Token = ({ token }) => {
const ua = UAParser(props.data.get("user_agent")); const ua = UAParser(token.user_agent);
return ( return (
<div className="card mt-3"> <div className="card mt-3">
<div className="card-header"> <div className="card-header">
<h4> <h4>
<Logo {...ua} /> <Logo {...ua} />
<span className="ml-3">{props.data.get("description")}</span> <span className="ml-3">{token.description}</span>
<Actions {...props} /> <Actions token={token.token} />
</h4> </h4>
</div> </div>
<div className="card-body row"> <div className="card-body row">
<div className="col-12 col-md-6"> <div className="col-12 col-md-6">
<p>Last IP: {props.data.get("ip")}</p> <p>Last IP: {token.ip}</p>
<p>Last used: {moment(props.data.get("last_used")).fromNow()}</p> <p>Last used: {moment(token.last_used).fromNow()}</p>
<p>Created: {moment(props.data.get("created_at")).fromNow()}</p> <p>Created: {moment(token.created_at).fromNow()}</p>
</div> </div>
<div className="col-12 col-md-6"> <div className="col-12 col-md-6">
<p> <p>
@ -64,12 +58,14 @@ const Token = (props) => {
); );
}; };
Token.propTypes = { Token.propTypes = {
data: PropTypes.instanceOf(Map).isRequired, token: PropTypes.object.isRequired,
}; };
const Actions = (props) => { const Actions = ({ token }) => {
const dispatch = useDispatch();
const handleClick = () => { const handleClick = () => {
props.deleteToken(props.data.get("token")); dispatch(deleteUserToken(token));
}; };
return ( return (
@ -80,8 +76,7 @@ const Actions = (props) => {
); );
}; };
Actions.propTypes = { Actions.propTypes = {
data: PropTypes.instanceOf(Map).isRequired, token: PropTypes.string.isRequired,
deleteToken: PropTypes.func.isRequired,
}; };
const Logo = ({ ua, device, browser }) => { const Logo = ({ ua, device, browser }) => {
@ -165,5 +160,3 @@ Browser.propTypes = {
name: PropTypes.string, name: PropTypes.string,
version: PropTypes.string, version: PropTypes.string,
}; };
export default UserTokens;

View File

@ -4,10 +4,10 @@ import { setFetchedTorrents } from "../actions/torrents";
import { newMovieEvent } from "../actions/movies"; import { newMovieEvent } from "../actions/movies";
import { newEpisodeEvent } from "../actions/shows"; import { newEpisodeEvent } from "../actions/shows";
const WsHandler = () => { export const WsHandler = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const isLogged = useSelector((state) => state.userStore.get("isLogged")); const isLogged = useSelector((state) => state.user.isLogged);
const [ws, setWs] = useState(null); const [ws, setWs] = useState(null);
@ -107,5 +107,3 @@ const WsHandler = () => {
return null; return null;
}; };
export default WsHandler;

View File

@ -1,26 +1,39 @@
import { Map, List, fromJS } from "immutable"; import { produce } from "immer";
export const defaultState = Map({ export const defaultState = {
fetchingModules: false, fetchingModules: false,
users: List(), users: new Map(),
stats: Map({}), stats: {},
modules: Map({}), modules: {},
});
const handlers = {
ADMIN_LIST_USERS_FULFILLED: (state, action) =>
state.set("users", fromJS(action.payload.response.data)),
ADMIN_GET_STATS_FULFILLED: (state, action) =>
state.set("stats", fromJS(action.payload.response.data)),
ADMIN_GET_MODULES_PENDING: (state) => state.set("fetchingModules", true),
ADMIN_GET_MODULES_FULFILLED: (state, action) =>
state.merge(
fromJS({
modules: action.payload.response.data,
fetchingModules: false,
})
),
}; };
export default (state = defaultState, action) => export default (state = defaultState, action) =>
handlers[action.type] ? handlers[action.type](state, action) : state; produce(state, (draft) => {
switch (action.type) {
case "ADMIN_LIST_USERS_FULFILLED": {
action.payload.response.data.forEach((user) => {
draft.users.set(user.id, user);
});
break;
}
case "ADMIN_GET_STATS_FULFILLED": {
draft.stats = action.payload.response.data;
break;
}
case "ADMIN_GET_MODULES_PENDING": {
draft.fetchingModules = true;
break;
}
case "ADMIN_GET_MODULES_FULFILLED": {
draft.fetchingModules = false;
draft.modules = action.payload.response.data;
break;
}
default:
return draft;
}
});

View File

@ -1,37 +1,33 @@
import { Map } from "immutable"; import { produce } from "immer";
const defaultState = Map({ const defaultState = {
show: false, show: false,
message: "", message: "",
type: "", type: "",
});
const handlers = {
ADD_ALERT_ERROR: (state, action) =>
state.merge(
Map({
message: action.payload.message,
show: true,
type: "error",
})
),
ADD_ALERT_OK: (state, action) =>
state.merge(
Map({
message: action.payload.message,
show: true,
type: "success",
})
),
DISMISS_ALERT: (state) =>
state.merge(
Map({
message: "",
show: false,
type: "",
})
),
}; };
export default (state = defaultState, action) => export default (state = defaultState, action) =>
handlers[action.type] ? handlers[action.type](state, action) : state; produce(state, (draft) => {
switch (action.type) {
case "ADD_ALERT_ERROR":
draft.show = true;
draft.type = "error";
draft.message = action.payload.message;
break;
case "ADD_ALERT_OK":
draft.show = true;
draft.type = "success";
draft.message = action.payload.message;
break;
case "DISMISS_ALERT":
draft.show = false;
draft.type = "";
draft.message = "";
break;
default:
return draft;
}
});

View File

@ -1,23 +1,27 @@
import { combineReducers } from "redux"; import { combineReducers } from "redux";
import movieStore from "./movies"; // Immer
import showsStore from "./shows"; import { enableMapSet } from "immer";
import showStore from "./show"; enableMapSet();
import userStore from "./users";
import movies from "./movies";
import shows from "./shows";
import show from "./show";
import user from "./users";
import alerts from "./alerts"; import alerts from "./alerts";
import torrentStore from "./torrents"; import torrents from "./torrents";
import adminStore from "./admins"; import admin from "./admins";
import polochon from "./polochon"; import polochon from "./polochon";
import notifications from "./notifications"; import notifications from "./notifications";
export default combineReducers({ export default combineReducers({
movieStore, movies,
showsStore, shows,
showStore, show,
userStore, user,
alerts, alerts,
torrentStore, torrents,
adminStore, admin,
polochon, polochon,
notifications, notifications,
}); });

View File

@ -1,100 +1,106 @@
import { OrderedMap, Map, fromJS } from "immutable"; import { produce } from "immer";
import { formatTorrents } from "../utils";
const defaultState = Map({ const defaultState = {
loading: false, loading: false,
movies: OrderedMap(), movies: new Map(),
filter: "",
selectedImdbId: "", selectedImdbId: "",
lastFetchUrl: "", lastFetchUrl: "",
exploreOptions: Map(), exploreOptions: {},
}); };
const handlers = { const formatMovie = (movie) => {
MOVIE_LIST_FETCH_PENDING: (state) => state.set("loading", true),
MOVIE_LIST_FETCH_ERROR: (state) => state.set("loading", false),
MOVIE_LIST_FETCH_FULFILLED: (state, action) => {
let allMoviesInPolochon = true;
let movies = Map();
action.payload.response.data.map(function (movie) {
movie.fetchingDetails = false; movie.fetchingDetails = false;
movie.fetchingSubtitles = false; movie.fetchingSubtitles = false;
movie.torrents = formatTorrents(movie.torrents);
return movie;
};
const formatMovies = (movies = []) => {
let allMoviesInPolochon = true;
movies.map((movie) => {
formatMovie(movie);
if (movie.polochon_url === "") { if (movie.polochon_url === "") {
allMoviesInPolochon = false; allMoviesInPolochon = false;
} }
movies = movies.set(movie.imdb_id, fromJS(movie));
}); });
// Select the first movie if the list is not empty movies.sort((a, b) => {
let selectedImdbId = "";
if (movies.size > 0) {
// Sort by year
movies = movies.sort((a, b) => {
if (!allMoviesInPolochon) { if (!allMoviesInPolochon) {
return b.get("year") - a.get("year"); return b.year - a.year;
} }
const dateA = new Date(a.get("date_added")); const dateA = new Date(a.date_added);
const dateB = new Date(b.get("date_added")); const dateB = new Date(b.date_added);
return dateA > dateB ? -1 : dateA < dateB ? 1 : 0; return dateA > dateB ? -1 : dateA < dateB ? 1 : 0;
}); });
selectedImdbId = movies.first().get("imdb_id"); let m = new Map();
} movies.forEach((movie) => {
m.set(movie.imdb_id, movie);
});
return state.delete("movies").merge( return m;
Map({
movies: movies,
filter: "",
loading: false,
selectedImdbId: selectedImdbId,
})
);
},
MOVIE_GET_DETAILS_PENDING: (state, action) =>
state.setIn(
["movies", action.payload.main.imdbId, "fetchingDetails"],
true
),
MOVIE_GET_DETAILS_FULFILLED: (state, action) =>
state
.setIn(
["movies", action.payload.response.data.imdb_id],
fromJS(action.payload.response.data)
)
.setIn(
["movies", action.payload.response.data.imdb_id, "fetchingDetails"],
false
)
.setIn(
["movies", action.payload.response.data.imdb_id, "fetchingSubtitles"],
false
),
MOVIE_UPDATE_STORE_WISHLIST: (state, action) =>
state.setIn(
["movies", action.payload.imdbId, "wishlisted"],
action.payload.wishlisted
),
MOVIE_GET_EXPLORE_OPTIONS_FULFILLED: (state, action) =>
state.set("exploreOptions", fromJS(action.payload.response.data)),
UPDATE_LAST_MOVIE_FETCH_URL: (state, action) =>
state.set("lastFetchUrl", action.payload.url),
MOVIE_SUBTITLES_UPDATE_PENDING: (state, action) =>
state.setIn(
["movies", action.payload.main.imdbId, "fetchingSubtitles"],
true
),
MOVIE_SUBTITLES_UPDATE_FULFILLED: (state, action) =>
state
.setIn(["movies", action.payload.main.imdbId, "fetchingSubtitles"], false)
.setIn(
["movies", action.payload.main.imdbId, "subtitles"],
fromJS(action.payload.response.data)
),
SELECT_MOVIE: (state, action) =>
state.set("selectedImdbId", action.payload.imdbId),
MOVIE_UPDATE_FILTER: (state, action) =>
state.set("filter", action.payload.filter),
}; };
export default (state = defaultState, action) => export default (state = defaultState, action) =>
handlers[action.type] ? handlers[action.type](state, action) : state; produce(state, (draft) => {
switch (action.type) {
case "MOVIE_LIST_FETCH_PENDING":
draft.loading = true;
break;
case "MOVIE_LIST_FETCH_ERROR":
draft.loading = false;
break;
case "MOVIE_LIST_FETCH_FULFILLED":
draft.loading = false;
draft.movies = formatMovies(action.payload.response.data);
if (draft.movies.size > 0) {
draft.selectedImdbId = draft.movies.keys().next().value;
}
break;
case "MOVIE_GET_DETAILS_PENDING":
draft.movies.get(action.payload.main.imdbId).fetchingDetails = true;
break;
case "MOVIE_GET_DETAILS_FULFILLED":
draft.movies.set(
action.payload.response.data.imdb_id,
formatMovie(action.payload.response.data)
);
break;
case "MOVIE_UPDATE_STORE_WISHLIST":
draft.movies.get(action.payload.imdbId).wishlisted =
action.payload.wishlisted;
break;
case "MOVIE_GET_EXPLORE_OPTIONS_FULFILLED":
draft.exploreOptions = action.payload.response.data;
break;
case "UPDATE_LAST_MOVIE_FETCH_URL":
draft.lastFetchUrl = action.payload.url;
break;
case "MOVIE_SUBTITLES_UPDATE_PENDING":
draft.movies.get(action.payload.main.imdbId).fetchingSubtitles = true;
break;
case "MOVIE_SUBTITLES_UPDATE_FULFILLED":
draft.movies.get(action.payload.main.imdbId).fetchingSubtitles = false;
draft.movies.get(action.payload.main.imdbId).subtitles =
action.payload.response.data;
break;
case "SELECT_MOVIE":
draft.selectedImdbId = action.payload.imdbId;
break;
default:
return draft;
}
});

View File

@ -1,18 +1,24 @@
import { List, fromJS } from "immutable"; import { produce } from "immer";
const defaultState = List(); const defaultState = new Map();
const handlers = {
ADD_NOTIFICATION: (state, action) =>
state.push(
fromJS({
id: Math.random().toString(36).substring(7),
...action.payload,
})
),
REMOVE_NOTIFICATION: (state, action) =>
state.filter((e) => e.get("id") !== action.payload.id),
};
export default (state = defaultState, action) => export default (state = defaultState, action) =>
handlers[action.type] ? handlers[action.type](state, action) : state; produce(state, (draft) => {
switch (action.type) {
case "ADD_NOTIFICATION": {
let id = Math.random().toString(36).substring(7);
draft.set(id, {
id: id,
...action.payload,
});
break;
}
case "REMOVE_NOTIFICATION":
draft.delete(action.payload.id);
break;
default:
return draft;
}
});

View File

@ -1,30 +1,34 @@
import { List, Map, fromJS } from "immutable"; import { produce } from "immer";
const defaultState = Map({ const defaultState = {
loadingPublic: false, loadingPublic: false,
loadingManaged: false, loadingManaged: false,
public: List(), public: [],
managed: List(), managed: [],
});
const handlers = {
PUBLIC_POLOCHON_LIST_FETCH_PENDING: (state) =>
state.set("loadingPublic", true),
PUBLIC_POLOCHON_LIST_FETCH_FULFILLED: (state, action) => {
return state.merge({
loadingPublic: false,
public: List(fromJS(action.payload.response.data)),
});
},
MANAGED_POLOCHON_LIST_FETCH_PENDING: (state) =>
state.set("loadingManaged", true),
MANAGED_POLOCHON_LIST_FETCH_FULFILLED: (state, action) => {
return state.merge({
loadingManaged: false,
managed: List(fromJS(action.payload.response.data)),
});
},
}; };
export default (state = defaultState, action) => export default (state = defaultState, action) =>
handlers[action.type] ? handlers[action.type](state, action) : state; produce(state, (draft) => {
switch (action.type) {
case "PUBLIC_POLOCHON_LIST_FETCH_PENDING":
draft.loadingPublic = true;
break;
case "PUBLIC_POLOCHON_LIST_FETCH_FULFILLED":
draft.loadingPublic = false;
draft.public = action.payload.response.data;
break;
case "MANAGED_POLOCHON_LIST_FETCH_PENDING":
draft.loadingManaged = true;
break;
case "MANAGED_POLOCHON_LIST_FETCH_FULFILLED":
draft.loadingManaged = false;
draft.managed = action.payload.response.data;
break;
default:
return draft;
}
});

View File

@ -1,117 +1,109 @@
import { OrderedMap, Map, fromJS } from "immutable"; import { produce } from "immer";
import { formatTorrents } from "../utils";
const defaultState = Map({ const defaultState = {
loading: false, loading: false,
show: Map({ show: {},
seasons: OrderedMap(),
}),
});
const handlers = {
SHOW_FETCH_DETAILS_PENDING: (state) => state.set("loading", true),
SHOW_FETCH_DETAILS_FULFILLED: (state, action) =>
sortEpisodes(state, action.payload.response.data),
SHOW_UPDATE_STORE_WISHLIST: (state, action) => {
return state.mergeDeep(
fromJS({
show: {
tracked_season: action.payload.season, // eslint-disable-line camelcase
tracked_episode: action.payload.episode, // eslint-disable-line camelcase
},
})
);
},
EPISODE_GET_DETAILS_PENDING: (state, action) =>
state.setIn(
[
"show",
"seasons",
action.payload.main.season,
action.payload.main.episode,
"fetching",
],
true
),
EPISODE_GET_DETAILS_FULFILLED: (state, action) => {
let data = action.payload.response.data;
if (!data) {
return state;
}
data.fetching = false;
return state.setIn(
["show", "seasons", data.season, data.episode],
fromJS(data)
);
},
EPISODE_SUBTITLES_UPDATE_PENDING: (state, action) =>
state.setIn(
[
"show",
"seasons",
action.payload.main.season,
action.payload.main.episode,
"fetchingSubtitles",
],
true
),
EPISODE_SUBTITLES_UPDATE_FULFILLED: (state, action) =>
state
.setIn(
[
"show",
"seasons",
action.payload.main.season,
action.payload.main.episode,
"subtitles",
],
fromJS(action.payload.response.data)
)
.setIn(
[
"show",
"seasons",
action.payload.main.season,
action.payload.main.episode,
"fetchingSubtitles",
],
false
),
}; };
const sortEpisodes = (state, show) => { const formatEpisode = (episode) => {
let episodes = show.episodes; // Format the episode's torrents
delete show["episodes"]; episode.torrents = formatTorrents(episode.torrents);
let ret = state.set("loading", false); // Set the default fetching data
if (episodes.length == 0) { episode.fetching = false;
return ret; episode.fetchingSubtitles = false;
}
// Set the show data
ret = ret.set("show", fromJS(show));
// Set the show episodes
for (let ep of episodes) {
ep.fetching = false;
ret = ret.setIn(["show", "seasons", ep.season, ep.episode], fromJS(ep));
}
// Sort the episodes
ret = ret.updateIn(["show", "seasons"], function (seasons) {
return seasons.map(function (episodes) {
return episodes.sort((a, b) => a.get("episode") - b.get("episode"));
});
});
// Sort the seasons
ret = ret.updateIn(["show", "seasons"], function (seasons) {
return seasons.sort(function (a, b) {
return a.first().get("season") - b.first().get("season");
});
});
return ret;
}; };
export default (state = defaultState, action) => export default (state = defaultState, action) =>
handlers[action.type] ? handlers[action.type](state, action) : state; produce(state, (draft) => {
switch (action.type) {
case "SHOW_FETCH_DETAILS_PENDING":
draft.loading = true;
break;
case "SHOW_FETCH_DETAILS_FULFILLED": {
let show = action.payload.response.data;
let episodes = show.episodes;
delete show.episodes;
draft.show = show;
let seasons = {};
let seasonNumbers = [];
if (!episodes) {
episodes = [];
}
episodes.forEach((episode) => {
// Skip special episodes
if (episode.season === 0) {
return;
}
// Format the episode from polochon
formatEpisode(episode);
if (!seasons[episode.season]) {
seasons[episode.season] = [];
seasonNumbers.push(episode.season);
}
seasons[episode.season].push(episode);
});
let seasonMap = new Map();
seasonNumbers.sort().forEach((n) => {
let episodes = [];
seasons[n]
.sort((a, b) => a.episode - b.episode)
.forEach((episode) => {
episodes.push([episode.episode, episode]);
});
seasonMap.set(n, new Map(episodes));
});
draft.show.seasons = seasonMap;
draft.loading = false;
break;
}
case "SHOW_UPDATE_STORE_WISHLIST":
// TODO: check with we give the imdb in the payload
draft.show.tracked_season = action.payload.season; // eslint-disable-line camelcase
draft.show.tracked_episode = action.payload.episode; // eslint-disable-line camelcase
break;
case "EPISODE_GET_DETAILS_PENDING":
draft.show.seasons
.get(action.payload.main.season)
.get(action.payload.main.episode).fetching = true;
break;
case "EPISODE_GET_DETAILS_FULFILLED": {
let episode = action.payload.response.data;
if (!episode) {
return draft;
}
formatEpisode(episode);
draft.show.get(episode.season).set(episode.episode, episode);
break;
}
case "EPISODE_SUBTITLES_UPDATE_PENDING":
draft.show.seasons
.get(action.payload.main.season)
.get(action.payload.main.episode).fetchingSubtitles = true;
break;
case "EPISODE_SUBTITLES_UPDATE_FULFILLED": {
draft.show.seasons
.get(action.payload.main.season)
.get(action.payload.main.episode).subtitles =
action.payload.response.data;
draft.show.seasons
.get(action.payload.main.season)
.get(action.payload.main.episode).fetchingSubtitles = false;
break;
}
default:
return draft;
}
});

View File

@ -1,77 +1,83 @@
import { OrderedMap, Map, fromJS } from "immutable"; import { produce } from "immer";
const defaultState = Map({ const defaultState = {
loading: false, loading: false,
shows: OrderedMap(), shows: new Map(),
filter: "",
selectedImdbId: "", selectedImdbId: "",
lastFetchUrl: "", lastFetchUrl: "",
exploreOptions: Map(), exploreOptions: {},
}); };
const handlers = { const formatShow = (show) => {
SHOW_LIST_FETCH_PENDING: (state) => state.set("loading", true),
SHOW_LIST_FETCH_FULFILLED: (state, action) => {
let shows = Map();
action.payload.response.data.map(function (show) {
show.fetchingDetails = false; show.fetchingDetails = false;
show.fetchingSubtitles = false; show.fetchingSubtitles = false;
shows = shows.set(show.imdb_id, fromJS(show)); show.episodes = undefined;
}); return show;
};
const formatShows = (shows = []) => {
// Add defaults
shows.map((show) => formatShow(show));
let selectedImdbId = "";
if (shows.size > 0) {
// Sort by year // Sort by year
shows = shows.sort((a, b) => b.get("year") - a.get("year")); shows.sort((a, b) => b.year - a.year);
selectedImdbId = shows.first().get("imdb_id");
}
return state.delete("shows").merge( let s = new Map();
Map({ shows.forEach((show) => {
shows: shows, s.set(show.imdb_id, show);
filter: "", });
loading: false,
selectedImdbId: selectedImdbId,
})
);
},
SHOW_GET_DETAILS_PENDING: (state, action) =>
state.setIn(["shows", action.payload.main.imdbId, "fetchingDetails"], true),
SHOW_GET_DETAILS_FULFILLED: (state, action) => {
let show = action.payload.response.data;
show.fetchingDetails = false;
show.fetchingSubtitles = false;
return state.setIn(["shows", show.imdb_id], fromJS(show));
},
SHOW_GET_EXPLORE_OPTIONS_FULFILLED: (state, action) =>
state.set("exploreOptions", fromJS(action.payload.response.data)),
SHOW_UPDATE_STORE_WISHLIST: (state, action) => {
let season = action.payload.season;
let episode = action.payload.episode;
if (action.payload.wishlisted) {
if (season === null) {
season = 0;
}
if (episode === null) {
episode = 0;
}
}
return state.mergeIn( return s;
["shows", action.payload.imdbId],
Map({
tracked_season: season, // eslint-disable-line camelcase
tracked_episode: episode, // eslint-disable-line camelcase
})
);
},
UPDATE_LAST_SHOWS_FETCH_URL: (state, action) =>
state.set("lastFetchUrl", action.payload.url),
SELECT_SHOW: (state, action) =>
state.set("selectedImdbId", action.payload.imdbId),
SHOWS_UPDATE_FILTER: (state, action) =>
state.set("filter", action.payload.filter),
}; };
export default (state = defaultState, action) => export default (state = defaultState, action) =>
handlers[action.type] ? handlers[action.type](state, action) : state; produce(state, (draft) => {
switch (action.type) {
case "SHOW_LIST_FETCH_PENDING":
draft.loading = true;
break;
case "SHOW_LIST_FETCH_FULFILLED":
draft.loading = false;
draft.shows = formatShows(action.payload.response.data);
if (draft.shows.size > 0) {
draft.selectedImdbId = draft.shows.keys().next().value;
}
break;
case "SHOW_GET_DETAILS_PENDING":
draft.shows.get(action.payload.main.imdbId).fetchingDetails = true;
break;
case "SHOW_GET_DETAILS_FULFILLED":
draft.shows.set(
action.payload.response.data.imdb_id,
formatShow(action.payload.response.data)
);
break;
case "SHOW_GET_EXPLORE_OPTIONS_FULFILLED":
draft.exploreOptions = action.payload.response.data;
break;
case "SHOW_UPDATE_STORE_WISHLIST":
// eslint-disable-next-line camelcase
draft.shows.get(action.payload.imdbId).tracked_season =
action.payload.season;
// eslint-disable-next-line camelcase
draft.shows.get(action.payload.imdbId).tracked_episode =
action.payload.episode; // eslint-disable-line camelcase
break;
case "UPDATE_LAST_SHOWS_FETCH_URL":
draft.lastFetchUrl = action.payload.url;
break;
case "SELECT_SHOW":
draft.selectedImdbId = action.payload.imdbId;
break;
default:
return draft;
}
});

View File

@ -1,30 +1,34 @@
import { Map, List, fromJS } from "immutable"; import { produce } from "immer";
const defaultState = Map({ const defaultState = {
fetching: false, fetching: false,
searching: false, searching: false,
torrents: List(), torrents: [],
searchResults: List(), searchResults: [],
});
const handlers = {
TORRENTS_FETCH_PENDING: (state) => state.set("fetching", true),
TORRENTS_FETCH_FULFILLED: (state, action) =>
state.merge(
fromJS({
fetching: false,
torrents: action.payload.response.data,
})
),
TORRENTS_SEARCH_PENDING: (state) => state.set("searching", true),
TORRENTS_SEARCH_FULFILLED: (state, action) =>
state.merge(
fromJS({
searching: false,
searchResults: action.payload.response.data,
})
),
}; };
export default (state = defaultState, action) => export default (state = defaultState, action) =>
handlers[action.type] ? handlers[action.type](state, action) : state; produce(state, (draft) => {
switch (action.type) {
case "TORRENTS_FETCH_PENDING":
draft.fetching = true;
break;
case "TORRENTS_FETCH_FULFILLED":
draft.fetching = false;
draft.torrents = action.payload.response.data;
break;
case "TORRENTS_SEARCH_PENDING":
draft.searching = true;
break;
case "TORRENTS_SEARCH_FULFILLED":
draft.searching = false;
draft.searchResults = action.payload.response.data;
break;
default:
return draft;
}
});

View File

@ -1,9 +1,9 @@
import { Map, List, fromJS } from "immutable"; import { produce } from "immer";
import jwtDecode from "jwt-decode"; import jwtDecode from "jwt-decode";
import Cookies from "universal-cookie"; import Cookies from "universal-cookie";
const defaultState = Map({ const defaultState = {
error: "", error: "",
loading: false, loading: false,
username: "", username: "",
@ -16,98 +16,102 @@ const defaultState = Map({
polochonName: "", polochonName: "",
polochonId: "", polochonId: "",
polochonActivated: false, polochonActivated: false,
tokens: List(), tokens: [],
modules: Map(), modules: {},
modulesLoading: false, modulesLoading: false,
});
let initialState = defaultState;
const token = localStorage.getItem("token");
if (token && token !== "") {
initialState = updateFromToken(initialState, token);
}
const handlers = {
USER_LOGIN_PENDING: (state) => state.set("loading", true),
USER_LOGIN_ERROR: (state, action) => {
state.set("loading", false);
return logoutUser(action.payload.response.message);
},
USER_LOGIN_FULFILLED: (state, action) => {
return updateFromToken(state, action.payload.response.data.token);
},
USER_SIGNUP_PENDING: (state) => state.set("loading", true),
USER_SIGNUP_ERROR: (state, action) =>
state.merge(
Map({
error: action.payload.response.message,
loading: false,
})
),
USER_SIGNUP_FULFILLED: (state) =>
state.merge(Map({ error: "", loading: false })),
USER_SET_TOKEN: (state, action) =>
updateFromToken(state, action.payload.token),
USER_LOGOUT: () => logoutUser(),
GET_USER_PENDING: (state) => state.set("loading", true),
GET_USER_FULFILLED: (state, action) =>
state.merge(
Map({
polochonToken: action.payload.response.data.token,
polochonUrl: action.payload.response.data.url,
polochonName: action.payload.response.data.name,
polochonId: action.payload.response.data.id,
polochonActivated: action.payload.response.data.activated,
loading: false,
})
),
GET_USER_TOKENS_PENDING: (state) => state.set("loading", true),
GET_USER_TOKENS_FULFILLED: (state, action) =>
state.merge(
Map({
tokens: fromJS(action.payload.response.data),
loading: false,
})
),
GET_USER_MODULES_PENDING: (state) => state.set("modulesLoading", true),
GET_USER_MODULES_FULFILLED: (state, action) =>
state.merge(
Map({
modules: fromJS(action.payload.response.data),
modulesLoading: false,
})
),
}; };
function logoutUser(error) { const logoutUser = () => {
localStorage.removeItem("token"); localStorage.removeItem("token");
const cookies = new Cookies(); const cookies = new Cookies();
cookies.remove("token"); cookies.remove("token");
if (error !== "") { };
return defaultState.set("error", error);
} else {
return defaultState;
}
}
function updateFromToken(state, token) { const updateFromToken = (draft, token) => {
const decodedToken = jwtDecode(token); const decodedToken = jwtDecode(token);
localStorage.setItem("token", token); localStorage.setItem("token", token);
const cookies = new Cookies(); const cookies = new Cookies();
cookies.set("token", token); cookies.set("token", token);
return state.merge( draft.error = "";
Map({ draft.isLogged = true;
error: "", draft.isTokenSet = true;
isLogged: true, draft.isAdmin = decodedToken.isAdmin;
isTokenSet: true, draft.isActivated = decodedToken.isActivated;
isAdmin: decodedToken.isAdmin, draft.username = decodedToken.username;
isActivated: decodedToken.isActivated, };
username: decodedToken.username,
}) // Initial state is the default state with the info from the current user
); // token
let initialState = defaultState;
const token = localStorage.getItem("token");
if (token && token !== "") {
updateFromToken(initialState, token);
} }
export default (state = initialState, action) => export default (state = initialState, action) =>
handlers[action.type] ? handlers[action.type](state, action) : state; produce(state, (draft) => {
switch (action.type) {
case "USER_SIGNUP_PENDING":
case "USER_LOGIN_PENDING":
case "GET_USER_PENDING":
case "GET_USER_TOKENS_PENDING":
draft.loading = true;
break;
case "USER_LOGIN_ERROR":
logoutUser();
draft.isLogged = false;
draft.loading = false;
draft.error = action.payload.response.message;
break;
case "USER_LOGIN_FULFILLED":
draft.loading = false;
updateFromToken(draft, action.payload.response.data.token);
break;
case "USER_SIGNUP_ERROR":
draft.error = action.payload.response.message;
draft.loading = false;
break;
case "USER_SIGNUP_FULFILLED":
draft.error = "";
draft.loading = false;
break;
case "USER_SET_TOKEN":
updateFromToken(draft, action.payload.token);
break;
case "USER_LOGOUT":
logoutUser();
draft.isLogged = false;
break;
case "GET_USER_FULFILLED":
draft.polochonToken = action.payload.response.data.token;
draft.polochonUrl = action.payload.response.data.url;
draft.polochonName = action.payload.response.data.name;
draft.polochonId = action.payload.response.data.id;
draft.polochonActivated = action.payload.response.data.activated;
draft.loading = false;
break;
case "GET_USER_TOKENS_FULFILLED":
draft.tokens = action.payload.response.data;
draft.loading = false;
break;
case "GET_USER_MODULES_PENDING":
draft.modulesLoading = true;
break;
case "GET_USER_MODULES_FULFILLED":
draft.modules = action.payload.response.data;
draft.modulesLoading = false;
break;
}
});

View File

@ -21,43 +21,6 @@ const pad = (d) => (d < 10 ? "0" + d.toString() : d.toString());
export const prettyEpisodeName = (showName, season, episode) => export const prettyEpisodeName = (showName, season, episode) =>
`${showName} S${pad(season)}E${pad(episode)}`; `${showName} S${pad(season)}E${pad(episode)}`;
export const inLibrary = (element) => element.get("polochon_url", "") !== "";
export const isWishlisted = (element) => {
const wishlisted = element.get("wishlisted", undefined);
if (wishlisted !== undefined) {
return wishlisted;
}
const trackedSeason = element.get("tracked_season", null);
const trackedEpisode = element.get("tracked_episode", null);
if (trackedSeason !== null && trackedEpisode !== null) {
return true;
}
return false;
};
export const isEpisodeWishlisted = (element, trackedSeason, trackedEpisode) => {
if (trackedSeason === null && trackedEpisode === null) {
return false;
}
if (trackedSeason === 0 && trackedEpisode === 0) {
return true;
}
const season = element.get("season", 0);
const episode = element.get("episode", 0);
if (season < trackedSeason) {
return false;
} else if (season > trackedSeason) {
return true;
} else {
return episode >= trackedEpisode;
}
};
export const prettySize = (fileSizeInBytes) => { export const prettySize = (fileSizeInBytes) => {
var i = -1; var i = -1;
var byteUnits = [" kB", " MB", " GB", " TB", "PB", "EB", "ZB", "YB"]; var byteUnits = [" kB", " MB", " GB", " TB", "PB", "EB", "ZB", "YB"];
@ -68,3 +31,19 @@ export const prettySize = (fileSizeInBytes) => {
return Math.max(fileSizeInBytes, 0.1).toFixed(1) + byteUnits[i]; return Math.max(fileSizeInBytes, 0.1).toFixed(1) + byteUnits[i];
}; };
export const formatTorrents = (torrents = []) => {
if (!torrents || torrents.length == 0) {
return undefined;
}
let torrentMap = new Map();
torrents.forEach((torrent) => {
if (!torrentMap.has(torrent.source)) {
torrentMap.set(torrent.source, new Map());
}
torrentMap.get(torrent.source).set(torrent.quality, torrent);
});
return torrentMap;
};

View File

@ -5934,10 +5934,10 @@
"integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==",
"dev": true "dev": true
}, },
"immutable": { "immer": {
"version": "4.0.0-rc.12", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0-rc.12.tgz", "resolved": "https://registry.npmjs.org/immer/-/immer-6.0.3.tgz",
"integrity": "sha512-0M2XxkZLx/mi3t8NVwIm1g8nHoEmM9p9UBl/G9k4+hm0kBgOVdMV/B3CY5dQ8qG8qc80NN4gDV4HQv6FTJ5q7A==" "integrity": "sha512-12VvNrfSrXZdm/BJgi/KDW2soq5freVSf3I1+4CLunUM8mAGx2/0Njy0xBVzi5zewQZiwM7z1/1T+8VaI7NkmQ=="
}, },
"import-cwd": { "import-cwd": {
"version": "2.1.0", "version": "2.1.0",

View File

@ -11,7 +11,7 @@
"font-awesome": "^4.7.0", "font-awesome": "^4.7.0",
"fuzzy": "^0.1.3", "fuzzy": "^0.1.3",
"history": "^4.9.0", "history": "^4.9.0",
"immutable": "^4.0.0-rc.12", "immer": "^6.0.3",
"jquery": "^3.4.1", "jquery": "^3.4.1",
"jwt-decode": "^2.1.0", "jwt-decode": "^2.1.0",
"moment": "^2.20.1", "moment": "^2.20.1",