├── .env.local.example
├── .eslintrc.json
├── .gitignore
├── .prettierignore
├── .prettierrc
├── README.md
├── components
├── detailspage
│ ├── DetailsTable.jsx
│ ├── Files.jsx
│ ├── NFO.jsx
│ ├── Proof.jsx
│ ├── RetailInfo_Discogs.jsx
│ ├── RetailInfo_Movie_TMDB.jsx
│ ├── RetailInfo_TV.jsx
│ └── RetailInfo_TV_TMDB.jsx
├── layout
│ ├── Footer.jsx
│ ├── GithubLink.jsx
│ ├── Layout.jsx
│ ├── Logo.jsx
│ ├── MobileMenu.jsx
│ ├── Navbar.jsx
│ └── ThemeSwitcher.jsx
├── modals
│ └── ModalSubscribe.jsx
├── notifications
│ └── NotificationCard.jsx
├── profile
│ ├── ActionGroup.jsx
│ └── DeleteAccountModal.jsx
├── releases
│ ├── CategoryBadge.jsx
│ ├── CopyButon.jsx
│ ├── NoResults.jsx
│ ├── Pagination.jsx
│ ├── ReleaseList.jsx
│ └── ReleaseRow.jsx
└── search
│ ├── SearchAdvanced.jsx
│ ├── SearchSimple.jsx
│ ├── SearchTabs.jsx
│ └── Toolbar.jsx
├── context
└── SearchContext.jsx
├── hooks
├── useDebounce.js
├── useFetch.js
├── useLocalStorage.js
└── useSocket.js
├── next.config.js
├── package.json
├── pages
├── _app.jsx
├── about.jsx
├── api
│ └── hello.js
├── index.jsx
├── login.jsx
├── logout.jsx
├── notifications.jsx
├── profile.jsx
├── register.jsx
├── release
│ └── [_id].jsx
├── restore.jsx
├── rss.jsx
└── stats.jsx
├── public
├── dolittle.2020.multi.complete.uhd.bluray-orca-proof.jpg
├── favicon.ico
├── movie-placeholder-dark.png
├── movie-placeholder-light.png
└── settings-knobs.svg
├── redux
├── slices
│ ├── authSlice.js
│ ├── notificationSlice.js
│ ├── releasesSlice.js
│ ├── searchSlice.js
│ ├── statsSlice.js
│ └── toastSlice.js
└── store.js
├── styles
├── components
│ ├── button.js
│ ├── container.js
│ ├── index.js
│ └── input.js
├── foundations
│ ├── colors.js
│ ├── index.js
│ ├── radii.js
│ └── typography.js
├── global.css
├── index.js
└── styles.js
└── utils
├── classify.js
├── helpers.js
├── routes.js
└── sites.js
/.env.local.example:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_API_BASE=https://api.predb.live
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals",
3 | "rules": {
4 | "react/no-unescaped-entities": 0
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | .directory
21 | package-lock.json
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env.local
30 | .env.development.local
31 | .env.test.local
32 | .env.production.local
33 |
34 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | dist
2 | node_modules
3 | coverage
4 | .next
5 | build
6 | public
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "bracketSpacing": true,
3 | "jsxBracketSameLine": false,
4 | "jsxSingleQuote": false,
5 | "printWidth": 90,
6 | "proseWrap": "always",
7 | "semi": true,
8 | "singleQuote": false,
9 | "tabWidth": 2,
10 | "trailingComma": "all"
11 | }
12 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | ```
12 |
13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
14 |
15 | You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
16 |
17 | ## Learn More
18 |
19 | To learn more about Next.js, take a look at the following resources:
20 |
21 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
22 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
23 |
24 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
25 |
26 | ## Deploy on Vercel
27 |
28 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/import?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
29 |
30 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
31 |
--------------------------------------------------------------------------------
/components/detailspage/DetailsTable.jsx:
--------------------------------------------------------------------------------
1 | import { Box, Flex, Grid, Heading, Text, useColorModeValue } from "@chakra-ui/react";
2 | import TimeAgo from "timeago-react";
3 |
4 | const DetailsTable = ({ data, borderColor }) => {
5 | return (
6 |
14 |
19 |
20 |
21 |
22 | Release
23 |
24 | {data.name}
25 |
26 |
27 | Group
28 |
29 | {data.group}
30 |
31 |
32 | Added
33 |
34 |
35 | {new Date(data.added).toLocaleString()} (
36 | )
37 |
38 |
39 |
40 | Section
41 |
42 | {data.section}
43 |
44 |
45 | No. of files
46 |
47 | {data.files || "-"}
48 |
49 |
50 | Size
51 |
52 | {data.size ? `${data.size} MB` : "-"}
53 |
54 |
55 | Genre
56 |
57 | {data.genre || "-"}
58 |
59 |
60 | Retail link
61 |
62 | {data.url || "-"}
63 |
64 |
65 | Tracers
66 |
67 |
68 | {data.traces
69 | ? data.traces.map((tr) => `#${tr.rank} ${tr.site}`).join(", ")
70 | : "-"}
71 |
72 |
73 |
74 | Nukes
75 |
76 |
77 | {!data.nukes || data.nukes.length === 0 ? (
78 | "-"
79 | ) : (
80 |
81 | {data.nukes.map((nuke) => (
82 |
83 |
91 | [{nuke.type}]{" "}
92 |
93 | {nuke.reason}
94 |
95 | ))}
96 |
97 | )}
98 |
99 |
100 |
101 | );
102 | };
103 |
104 | export default DetailsTable;
105 |
--------------------------------------------------------------------------------
/components/detailspage/Files.jsx:
--------------------------------------------------------------------------------
1 | import { Box, Heading, useColorMode } from "@chakra-ui/react";
2 |
3 | const RetailInfo_Movie_TMDB = ({ data, borderColor }) => {
4 | const { colorMode } = useColorMode();
5 |
6 | console.log(data);
7 | if (!data) return null;
8 |
9 | return (
10 |
17 |
22 |
23 | );
24 | };
25 |
26 | export default RetailInfo_Movie_TMDB;
27 |
--------------------------------------------------------------------------------
/components/detailspage/NFO.jsx:
--------------------------------------------------------------------------------
1 | import { Box, Button, Image, useColorMode } from "@chakra-ui/react";
2 | import { FiDownload } from "react-icons/fi";
3 | import { API_BASE, API_ENDPOINT } from "../../utils/routes";
4 |
5 | const NFO = ({ data, borderColor }) => {
6 | const { colorMode } = useColorMode();
7 |
8 | if (!data.nfo) return null;
9 |
10 | const downloadLink = `${API_BASE + API_ENDPOINT.DOWNLOAD}/${data.name}/${
11 | data.nfo[0].filename
12 | }`;
13 |
14 | return (
15 |
22 |
35 |
36 | Loading...}
39 | mx="auto"
40 | alt=""
41 | />
42 |
43 | );
44 | };
45 |
46 | export default NFO;
47 |
--------------------------------------------------------------------------------
/components/detailspage/Proof.jsx:
--------------------------------------------------------------------------------
1 | import { DownloadIcon, Flex, IconButton, Image, Text } from "@chakra-ui/react";
2 |
3 | const Proof = ({ proof }) => {
4 | if (!proof) return null;
5 |
6 | return (
7 |
8 |
9 | dolittle.2020.multi.complete.uhd.bluray-orca-proof.jpg
10 |
17 |
18 |
26 |
27 | );
28 | };
29 |
30 | export default Proof;
31 |
--------------------------------------------------------------------------------
/components/detailspage/RetailInfo_Discogs.jsx:
--------------------------------------------------------------------------------
1 | export const RetailInfo_Discogs = ({ data }) => {
2 | return
{JSON.stringify(data, null, 4)}
;
3 | };
4 |
--------------------------------------------------------------------------------
/components/detailspage/RetailInfo_Movie_TMDB.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Flex,
4 | Grid,
5 | Heading,
6 | Image,
7 | Link,
8 | Stack,
9 | Text,
10 | useColorMode,
11 | } from "@chakra-ui/react";
12 | import TimeAgo from "timeago-react";
13 |
14 | const RetailInfo_Movie_TMDB = ({ data, borderColor }) => {
15 | const { colorMode } = useColorMode();
16 | const fallbackSrc = {
17 | dark: "/movie-placeholder-dark.png",
18 | light: "/movie-placeholder-light.png",
19 | };
20 |
21 | if (!data) return null;
22 |
23 | return (
24 |
31 |
36 |
37 |
43 |
44 |
45 | Title
46 |
47 |
48 | {data.original_title}
49 | {data.title !== data.original_title && ` (${data.title})`}
50 |
51 |
52 |
53 | Language
54 |
55 |
56 | {data.original_language ? data.original_language.toUpperCase() : "-"}
57 |
58 |
59 |
60 | Produced by
61 |
62 |
63 | {data.production_companies.length > 0
64 | ? data.production_companies.map((n) => n.name).join(", ")
65 | : "-"}
66 |
67 |
68 |
69 | Country
70 |
71 |
72 | {data.production_countries.length > 0
73 | ? data.production_countries.map((n) => n.name).join(", ")
74 | : "-"}
75 |
76 |
77 |
78 | Budget
79 |
80 |
81 | {data.budget
82 | ? "$ " + data.budget.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")
83 | : "-"}
84 |
85 |
86 |
87 | Revenue
88 |
89 |
90 | {data.revenue
91 | ? "$ " + data.revenue.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")
92 | : "-"}
93 |
94 |
95 |
96 | Runtime
97 |
98 | {data.runtime ? `${data.runtime} min` : "-"}
99 |
100 |
101 | Genre
102 |
103 |
104 | {data.genres.length > 0 ? data.genres.map((g) => g.name).join(", ") : "-"}
105 |
106 |
107 |
108 | IMDB
109 |
110 |
111 | {data.imdb_id ? (
112 |
113 | {`https://imdb.com/title/${data.imdb_id}`}
114 |
115 | ) : (
116 | "-"
117 | )}
118 |
119 |
120 |
121 | Rating
122 |
123 |
124 | {data.vote_count === 0
125 | ? "-"
126 | : `${data.vote_average} / 10 (${data.vote_count} votes, TMDB)`}
127 |
128 |
129 |
130 | Released
131 |
132 |
133 | {data.release_date || "-"} ()
134 |
135 |
136 |
137 | Overview
138 |
139 | {data.overview || "-"}
140 |
141 |
142 | Missing data? Fill it{" "}
143 |
144 | here!
145 |
146 |
147 |
148 |
149 |
164 |
165 |
166 | );
167 | };
168 |
169 | export default RetailInfo_Movie_TMDB;
170 |
--------------------------------------------------------------------------------
/components/detailspage/RetailInfo_TV.jsx:
--------------------------------------------------------------------------------
1 | import { Flex, Link, Grid, Heading, Image, Text } from "@chakra-ui/react";
2 | import TimeAgo from "timeago-react";
3 |
4 | const RetailTable = ({ data, borderColor }) => {
5 | console.log;
6 | if (!data)
7 | return (
8 |
9 | Retail info not available
10 |
11 | );
12 |
13 | return (
14 |
22 |
23 |
24 | Series name
25 |
26 | {data.seriesName}
27 |
28 |
29 | Network
30 |
31 | {data.network || "-"}
32 |
33 |
34 | First aired
35 |
36 |
37 | {data.firstAired} (
38 | )
39 |
40 |
41 |
42 | Air date
43 |
44 | {`${data.airsDayOfWeek}, ${data.airsTime}`}
45 |
46 |
47 | Runtime
48 |
49 | {`${data.runtime} min` || "-"}
50 |
51 |
52 | Genre
53 |
54 | {data.genre?.join(", ")}
55 |
56 |
57 | Imdb
58 |
59 |
60 |
61 | {`https://www.imdb.com/title/${data.imdbId}`}
62 |
63 |
64 |
65 |
66 | Rating
67 |
68 |
69 | {data.siteRating === 0
70 | ? "-"
71 | : `${data.siteRating} / 10 (${data.siteRatingCount} votes, TVDB)`}
72 |
73 |
74 |
75 | Status
76 |
77 | {data.status}
78 |
79 |
80 | Overview
81 |
82 | {data.overview || "-"}
83 |
84 |
85 |
94 |
95 | );
96 | };
97 |
98 | export default RetailTable;
99 |
--------------------------------------------------------------------------------
/components/detailspage/RetailInfo_TV_TMDB.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Grid,
4 | Heading,
5 | Image,
6 | Link,
7 | Stack,
8 | Text,
9 | useColorMode,
10 | useColorModeValue,
11 | } from "@chakra-ui/react";
12 | import TimeAgo from "timeago-react";
13 |
14 | const RetailInfo_TV_TMDB = ({ data, borderColor }) => {
15 | const { colorMode } = useColorMode();
16 | const fallbackSrc = {
17 | dark: "/movie-placeholder-dark.png",
18 | light: "/movie-placeholder-light.png",
19 | };
20 |
21 | console.log(data);
22 | if (!data) return null;
23 |
24 | return (
25 |
32 |
37 |
38 |
44 |
45 |
46 | Series name
47 |
48 |
49 | {data.original_name}
50 | {data.name !== data.original_name && ` (${data.name})`}
51 |
52 |
53 |
54 | Type
55 |
56 | {data.type || "-"}
57 |
58 |
59 | Country
60 |
61 |
62 | {data.origin_country.length > 0 ? data.origin_country.join(", ") : "-"}
63 |
64 |
65 |
66 | Network
67 |
68 |
69 | {data.networks.length > 0 ? data.networks.map((n) => n.name).join(", ") : "-"}
70 |
71 |
72 |
73 | First aired
74 |
75 |
76 | {data.first_air_date ? (
77 | <>
78 | {data.first_air_date} ()
79 | >
80 | ) : (
81 | "-"
82 | )}
83 |
84 |
85 |
86 | Runtime
87 |
88 |
89 | {data.episode_run_time.length > 0
90 | ? data.episode_run_time.map((t) => t + " min").join(", ")
91 | : "-"}
92 |
93 |
94 |
95 | Genre
96 |
97 |
98 | {data.genres.length > 0 ? data.genres.map((g) => g.name).join(", ") : "-"}
99 |
100 |
101 |
102 | Homepage
103 |
104 |
105 |
106 | {data.homepage || "-"}
107 |
108 |
109 |
110 |
111 | Rating
112 |
113 |
114 | {data.vote_count === 0
115 | ? "-"
116 | : `${data.vote_average} / 10 (${data.vote_count} votes, TMDB)`}
117 |
118 |
119 |
120 | Status
121 |
122 | {data.status || "-"}
123 |
124 |
125 | Overview
126 |
127 | {data.overview || "-"}
128 |
129 |
130 | Missing data? Fill it{" "}
131 |
132 | here!
133 |
134 |
135 |
136 |
137 |
153 |
154 |
155 | );
156 | };
157 |
158 | export default RetailInfo_TV_TMDB;
159 |
--------------------------------------------------------------------------------
/components/layout/Footer.jsx:
--------------------------------------------------------------------------------
1 | import { Box, Container, Flex, Link, Text } from "@chakra-ui/react";
2 | import NextLink from "next/link";
3 |
4 | export const Footer = () => {
5 | return (
6 |
7 |
15 |
21 | Made in Budapest, Hungary
22 |
23 | •
24 |
25 | Build: {process.env.NEXT_PUBLIC_GIT_SHA}
26 |
27 |
28 |
29 |
30 | About
31 |
32 |
33 | RSS
34 |
35 |
36 |
37 | API
38 |
39 |
40 |
41 |
42 |
43 | );
44 | };
45 |
--------------------------------------------------------------------------------
/components/layout/GithubLink.jsx:
--------------------------------------------------------------------------------
1 | import { IconButton } from "@chakra-ui/react";
2 | import { DiGithubBadge } from "react-icons/di";
3 |
4 | export const GithubLink = () => (
5 | }
7 | as="a"
8 | href="https://github.com/la55u/predb-frontend"
9 | target="_blank"
10 | rel="noopener noreferrer"
11 | title="Source code on Github"
12 | color="current"
13 | variant="ghost"
14 | fontSize="24px"
15 | />
16 | );
17 |
--------------------------------------------------------------------------------
/components/layout/Layout.jsx:
--------------------------------------------------------------------------------
1 | import { Box, Container, Flex } from "@chakra-ui/react";
2 | import { useEffect } from "react";
3 | import { useDispatch, useSelector } from "react-redux";
4 | import { getMe } from "../../redux/slices/authSlice";
5 | import { Footer } from "./Footer";
6 | import Navbar from "./Navbar";
7 | import { setAuthenticated } from "../../redux/slices/authSlice";
8 | import { useSocket } from "../../hooks/useSocket";
9 | import Head from "next/head";
10 |
11 | const Layout = ({ title, children }) => {
12 | const dispatch = useDispatch();
13 | useSocket();
14 |
15 | const { isAuthenticated, user } = useSelector((state) => state.auth);
16 |
17 | useEffect(() => {
18 | const t = localStorage.getItem("auth");
19 | if (t) {
20 | dispatch(setAuthenticated());
21 | }
22 | if (isAuthenticated && !user) dispatch(getMe());
23 | }, [isAuthenticated]);
24 |
25 | return (
26 | <>
27 |
28 | {title ? `${title} | ` : ""} PREdb | Warez Scene Database
29 |
30 |
31 |
32 |
33 |
34 |
35 | {children}
36 |
37 |
38 |
39 |
40 | >
41 | );
42 | };
43 |
44 | export default Layout;
45 |
--------------------------------------------------------------------------------
/components/layout/Logo.jsx:
--------------------------------------------------------------------------------
1 | import { Flex, Heading, Tag, Text } from "@chakra-ui/react";
2 | import Link from "next/link";
3 | import React, { useEffect } from "react";
4 | import { useDispatch, useSelector } from "react-redux";
5 | import { getCount } from "../../redux/slices/releasesSlice";
6 |
7 | const Logo = () => {
8 | const dispatch = useDispatch();
9 | const count = useSelector((state) => state.releases.count);
10 |
11 | useEffect(() => {
12 | if (!count) dispatch(getCount());
13 | }, []);
14 |
15 | return (
16 |
17 |
18 |
19 |
28 | PREdb
29 |
30 | .live
31 |
32 |
33 |
34 |
35 |
36 |
37 | Beta
38 |
39 |
40 |
41 | {!count ? "..." : `Indexing ${count?.toLocaleString()} releases`}
42 |
43 |
44 | );
45 | };
46 |
47 | export default Logo;
48 |
--------------------------------------------------------------------------------
/components/layout/MobileMenu.jsx:
--------------------------------------------------------------------------------
1 | import { BellIcon, InfoOutlineIcon, SearchIcon } from "@chakra-ui/icons";
2 | import {
3 | Box,
4 | Button,
5 | Icon,
6 | Link,
7 | Menu,
8 | MenuButton,
9 | MenuDivider,
10 | MenuItem,
11 | MenuList,
12 | } from "@chakra-ui/react";
13 | import NextLink from "next/link";
14 | import { AiFillApi, AiOutlineGithub } from "react-icons/ai";
15 | import { BsFillPersonFill } from "react-icons/bs";
16 | import { FiLogIn, FiLogOut } from "react-icons/fi";
17 | import { GiHamburgerMenu } from "react-icons/gi";
18 | import { IoIosStats } from "react-icons/io";
19 | import { useSelector } from "react-redux";
20 | import { ThemeSwitcher } from "./ThemeSwitcher";
21 |
22 | export const MobileMenu = () => {
23 | const user = useSelector((state) => state.auth.user);
24 |
25 | return (
26 |
27 |
28 |
29 |
104 |
105 | );
106 | };
107 |
--------------------------------------------------------------------------------
/components/layout/Navbar.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Button,
4 | Container,
5 | Flex,
6 | Icon,
7 | IconButton,
8 | Menu,
9 | MenuButton,
10 | MenuItem,
11 | MenuList,
12 | Stack,
13 | useColorModeValue,
14 | } from "@chakra-ui/react";
15 | import NextLink from "next/link";
16 | import { useEffect, useState } from "react";
17 | import { BsFillPersonFill } from "react-icons/bs";
18 | import { FiLogOut } from "react-icons/fi";
19 | import { useDispatch, useSelector } from "react-redux";
20 | import { logout } from "../../redux/slices/authSlice";
21 | import { addToast } from "../../redux/slices/toastSlice";
22 | import { GithubLink } from "./GithubLink";
23 | import Logo from "./Logo";
24 | import { MobileMenu } from "./MobileMenu";
25 | import { ThemeSwitcher } from "./ThemeSwitcher";
26 |
27 | const Navbar = () => {
28 | const dispatch = useDispatch();
29 | const user = useSelector((state) => state.auth.user);
30 | const colorvalues = useColorModeValue({ bg: "light.bg" }, { bg: "dark.bg" });
31 | const [scrolled, setScrolled] = useState(false);
32 |
33 | const handleLogout = () => {
34 | dispatch(logout());
35 | dispatch(addToast({ title: "You logged out!" }));
36 | };
37 |
38 | useEffect(() => {
39 | window.addEventListener(
40 | "scroll",
41 | () => {
42 | if (window.pageYOffset > 100) {
43 | setScrolled(true);
44 | } else {
45 | setScrolled(false);
46 | }
47 | },
48 | { passive: true },
49 | );
50 | }, []);
51 |
52 | return (
53 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
79 |
80 |
83 |
84 |
85 |
86 |
89 |
90 |
91 |
92 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 | {!user ? (
104 |
105 | }
113 | />
114 |
115 | ) : (
116 |
142 | )}
143 |
144 |
145 |
146 |
147 |
148 |
149 | );
150 | };
151 |
152 | export default Navbar;
153 |
--------------------------------------------------------------------------------
/components/layout/ThemeSwitcher.jsx:
--------------------------------------------------------------------------------
1 | import { MoonIcon, SunIcon } from "@chakra-ui/icons";
2 | import { IconButton, useColorMode, useColorModeValue } from "@chakra-ui/react";
3 |
4 | export const ThemeSwitcher = () => {
5 | const { toggleColorMode } = useColorMode();
6 | const icon = useColorModeValue(, );
7 |
8 | return (
9 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/components/modals/ModalSubscribe.jsx:
--------------------------------------------------------------------------------
1 | import { BellIcon } from "@chakra-ui/icons";
2 | import {
3 | Button,
4 | FormControl,
5 | FormHelperText,
6 | FormLabel,
7 | Input,
8 | Modal,
9 | ModalBody,
10 | ModalCloseButton,
11 | ModalContent,
12 | ModalFooter,
13 | ModalHeader,
14 | ModalOverlay,
15 | Select,
16 | Tooltip,
17 | useDisclosure,
18 | } from "@chakra-ui/react";
19 | import { FaSave } from "react-icons/fa";
20 | import { useDispatch, useSelector } from "react-redux";
21 | import { createNotification } from "../../redux/slices/notificationSlice";
22 |
23 | const ModalSubscribe = () => {
24 | const { isOpen, onOpen, onClose } = useDisclosure();
25 | const dispatch = useDispatch();
26 | const simpleSearch = useSelector((s) => s.search.simpleSearch);
27 | const user = useSelector((s) => s.auth.user);
28 |
29 | const handleSubmit = (e) => {
30 | e.preventDefault();
31 | const formdata = new FormData(e.target);
32 | const data = Object.fromEntries(formdata.entries());
33 | dispatch(
34 | createNotification({ data, search: { type: "simple", input: simpleSearch } }),
35 | ).then(() => {
36 | onClose();
37 | });
38 | };
39 |
40 | return (
41 | <>
42 |
50 | }
54 | onClick={user && simpleSearch ? onOpen : undefined}
55 | >
56 | Subscribe
57 |
58 |
59 |
60 |
61 |
62 |
63 | Notify me about new results
64 |
65 |
66 |
67 | Label
68 |
69 |
70 | Enter a label that helps you identify your search
71 |
72 |
73 |
74 |
75 | Notification type
76 |
85 |
86 |
87 |
88 |
89 | } mr={3}>
90 | Save
91 |
92 |
93 |
94 |
95 |
96 |
97 | >
98 | );
99 | };
100 |
101 | export default ModalSubscribe;
102 |
--------------------------------------------------------------------------------
/components/notifications/NotificationCard.jsx:
--------------------------------------------------------------------------------
1 | import { BellIcon, SearchIcon } from "@chakra-ui/icons";
2 | import { Box, Button, Flex, Heading, HStack, Text } from "@chakra-ui/react";
3 | import { AiFillTag } from "react-icons/ai";
4 | import { FiTrash2 } from "react-icons/fi";
5 | import { useDispatch } from "react-redux";
6 | import { removeNotification } from "../../redux/slices/notificationSlice";
7 |
8 | export const NotificationCard = ({ notification }) => {
9 | const dispatch = useDispatch();
10 |
11 | const handleRemove = () => {
12 | if (confirm("Are you sure?")) {
13 | dispatch(removeNotification(notification._id));
14 | }
15 | };
16 |
17 | return (
18 |
19 |
20 |
21 |
22 | {notification.data.label}
23 |
24 |
25 |
26 |
27 | Created at:{" "}
28 | {new Date(notification.data.createdAt).toLocaleString(undefined, {
29 | dateStyle: "long",
30 | timeStyle: "short",
31 | })}
32 |
33 |
34 | Notification type: {notification.data.type.replace(/^\w/, (c) => c.toUpperCase())}
35 |
36 | Search mode: {notification.data.searchType}
37 | Matched: {notification.data.matchCnt} times
38 | Last match: 2 days ago
39 |
40 |
41 | }>
42 | Test
43 |
44 | }>
45 | View matches
46 |
47 | }
51 | colorScheme="red"
52 | variant="outline"
53 | onClick={handleRemove}
54 | >
55 | Remove
56 |
57 |
58 |
59 | );
60 | };
61 |
--------------------------------------------------------------------------------
/components/profile/ActionGroup.jsx:
--------------------------------------------------------------------------------
1 | import { Box, Heading, useColorModeValue } from "@chakra-ui/react";
2 |
3 | export const ActionGroup = ({ title, children, ...rest }) => {
4 | const bg = useColorModeValue("blackAlpha.100", "whiteAlpha.50");
5 |
6 | return (
7 | <>
8 |
9 | {title}
10 |
11 |
12 | {children}
13 |
14 | >
15 | );
16 | };
17 |
--------------------------------------------------------------------------------
/components/profile/DeleteAccountModal.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | Modal,
4 | ModalBody,
5 | ModalCloseButton,
6 | ModalContent,
7 | ModalFooter,
8 | ModalHeader,
9 | ModalOverlay,
10 | Text,
11 | useDisclosure,
12 | } from "@chakra-ui/react";
13 | import { RiDeleteBin6Line } from "react-icons/ri";
14 | import { useDispatch } from "react-redux";
15 | import { deleteAccount } from "../../redux/slices/authSlice";
16 |
17 | export const DeleteAccountModal = () => {
18 | const dispatch = useDispatch();
19 | const { isOpen, onOpen, onClose } = useDisclosure();
20 |
21 | const handleDelete = () => {
22 | dispatch(deleteAccount());
23 | onClose();
24 | };
25 |
26 | return (
27 | <>
28 | }
32 | variant="outline"
33 | onClick={onOpen}
34 | >
35 | Delete account
36 |
37 |
38 |
39 |
40 |
41 | Are you sure?
42 |
43 |
44 |
45 | All data associated with your profile, such as email address, saved
46 | searches, triggers and settings will be deleted. This action can not be
47 | undone.
48 |
49 |
50 |
51 |
54 | }
57 | onClick={handleDelete}
58 | >
59 | Delete
60 |
61 |
62 |
63 |
64 | >
65 | );
66 | };
67 |
--------------------------------------------------------------------------------
/components/releases/CategoryBadge.jsx:
--------------------------------------------------------------------------------
1 | import { Button, useColorModeValue } from "@chakra-ui/react";
2 | import { FaBook, FaFootballBall } from "react-icons/fa";
3 | import { FiFilm, FiMonitor, FiPackage } from "react-icons/fi";
4 | import { IoIosMusicalNotes, IoMdSchool } from "react-icons/io";
5 | import { MdGamepad, MdHelp, MdWhatshot } from "react-icons/md";
6 | import { SECTIONS } from "../../utils/classify";
7 |
8 | const getBadgeData = (section) => {
9 | switch (section) {
10 | case SECTIONS.XXX_VIDEO:
11 | case SECTIONS.XXX_PICS:
12 | return { bg: "red.400", bgLight: "red.500", name: "XXX", icon: };
13 |
14 | case SECTIONS.TV_HD:
15 | case SECTIONS.TV_SD:
16 | case SECTIONS.TV_DISC:
17 | return { bg: "blue.400", bgLight: "blue.500", name: "TV", icon: };
18 |
19 | case SECTIONS.MUSIC_AUDIO:
20 | case SECTIONS.MUSIC_DICS:
21 | case SECTIONS.MUSIC_VIDEO:
22 | return {
23 | bg: "cyan.400",
24 | bgLight: "cyan.600",
25 | name: "Music",
26 | icon: ,
27 | };
28 |
29 | case SECTIONS.GAME_PC:
30 | case SECTIONS.GAME_PS:
31 | case SECTIONS.GAME_XBOX:
32 | case SECTIONS.GAME_NINTENDO:
33 | return {
34 | bg: "orange.400",
35 | bgLight: "orange.500",
36 | name: "Game",
37 | icon: ,
38 | color: "black",
39 | };
40 | case SECTIONS.MOVIE_HD:
41 | case SECTIONS.MOVIE_SD:
42 | case SECTIONS.MOVIE_DICS:
43 | return { bg: "green.400", bgLight: "green.600", name: "Movie", icon: };
44 | case SECTIONS.EBOOK:
45 | return { bg: "#b38b6d", bgLight: "yellow.700", name: "Book", icon: };
46 | case SECTIONS.APP_MAC:
47 | case SECTIONS.APP_WIN:
48 | case SECTIONS.APP_LINUX:
49 | case SECTIONS.APP_MOBILE:
50 | return {
51 | bg: "purple.400",
52 | bgLight: "purple.600",
53 | name: "App",
54 | icon: ,
55 | };
56 | case SECTIONS.TUTORIAL:
57 | return {
58 | bg: "yellow.400",
59 | bgLight: "yellow.500",
60 | name: "Educational",
61 | icon: ,
62 | };
63 | case SECTIONS.SPORT:
64 | return {
65 | bg: "teal.300",
66 | bgLight: "teal.500",
67 | name: "Sport",
68 | icon: ,
69 | };
70 | default:
71 | return {
72 | bg: "gray.400",
73 | bgLight: "gray.500",
74 | name: "Other",
75 | icon: ,
76 | color: "black",
77 | };
78 | }
79 | };
80 |
81 | const CategoryBadge = ({ section }) => {
82 | const badge = getBadgeData(section);
83 | const color = useColorModeValue(badge.bgLight, badge.bg);
84 | return (
85 | <>
86 |
95 | >
96 | );
97 | };
98 |
99 | export default CategoryBadge;
100 |
--------------------------------------------------------------------------------
/components/releases/CopyButon.jsx:
--------------------------------------------------------------------------------
1 | import { IconButton, useClipboard, useToast } from "@chakra-ui/react";
2 | import { HiCheck } from "react-icons/hi";
3 | import { RiFileCopyLine } from "react-icons/ri";
4 |
5 | const CopyButton = ({ value, ...rest }) => {
6 | const { onCopy, hasCopied } = useClipboard(value);
7 | const toast = useToast();
8 |
9 | const handleClick = () => {
10 | onCopy();
11 | toast({
12 | description: "Name copied to clipboard",
13 | status: "success",
14 | duration: 3000,
15 | isClosable: false,
16 | });
17 | };
18 |
19 | return (
20 | : }
28 | onClick={handleClick}
29 | {...rest}
30 | />
31 | );
32 | };
33 |
34 | export default CopyButton;
35 |
--------------------------------------------------------------------------------
/components/releases/NoResults.jsx:
--------------------------------------------------------------------------------
1 | import { Box, Flex, Text } from "@chakra-ui/react";
2 | import { MdZoomOut } from "react-icons/md";
3 |
4 | export const NoResults = () => (
5 |
12 |
13 | No results found
14 |
15 | );
16 |
--------------------------------------------------------------------------------
/components/releases/Pagination.jsx:
--------------------------------------------------------------------------------
1 | import { Button, Flex } from "@chakra-ui/react";
2 | import { useDispatch, useSelector } from "react-redux";
3 | import { getAllRelease } from "../../redux/slices/releasesSlice";
4 | import { searchSimple, setPage } from "../../redux/slices/searchSlice";
5 |
6 | export const Pagination = () => {
7 | const dispatch = useDispatch();
8 | const simpleSearch = useSelector((state) => state.search.simpleSearch);
9 | const resultsCnt = useSelector((state) => state.search.resultsCnt);
10 | const page = useSelector((state) => state.search.page);
11 |
12 | const handlePageChange = (p) => {
13 | if (resultsCnt) {
14 | // search is active, request next page of results
15 | dispatch(searchSimple({ input: simpleSearch, page: p }));
16 | } else {
17 | // not searched yet, get next page of all releases
18 | dispatch(getAllRelease(p));
19 | }
20 | dispatch(setPage(p));
21 | window.scrollTo(0, 0);
22 | };
23 |
24 | const pagesCnt = !resultsCnt ? 10 : Math.min(Math.ceil(resultsCnt / 30), 10);
25 |
26 | return (
27 |
28 | {[...Array(pagesCnt)].map((_, i) => (
29 |
39 | ))}
40 |
41 | );
42 | };
43 |
--------------------------------------------------------------------------------
/components/releases/ReleaseList.jsx:
--------------------------------------------------------------------------------
1 | import { Box, Flex, Spinner } from "@chakra-ui/react";
2 | import { useEffect } from "react";
3 | import { useDispatch, useSelector } from "react-redux";
4 | import { getAllRelease } from "../../redux/slices/releasesSlice";
5 | import { NoResults } from "./NoResults";
6 | import ReleaseRow from "./ReleaseRow";
7 |
8 | const ReleaseList = () => {
9 | const dispatch = useDispatch();
10 | const { releaselist, loading } = useSelector((state) => state.releases);
11 | const results = useSelector((s) => s.search.results);
12 | const took = useSelector((s) => s.search.took);
13 | const searchLoading = useSelector((s) => s.search.loading);
14 | //const page = useSelector((s) => s.search.page);
15 |
16 | // if took is set, the user is searching -> show results, otherwise show the latest releases
17 | const list = took > 0 ? results : releaselist;
18 |
19 | useEffect(() => {
20 | // only when not searching
21 | if (!took && releaselist.length === 0) dispatch(getAllRelease());
22 | }, []);
23 |
24 | if (loading || searchLoading)
25 | return (
26 |
27 |
28 |
29 | );
30 |
31 | if (took > 0 && results.length === 0) return ;
32 |
33 | return (
34 |
35 | {list.map((rel, i) => (
36 |
37 |
38 |
39 | ))}
40 |
41 | );
42 | };
43 |
44 | export default ReleaseList;
45 |
--------------------------------------------------------------------------------
/components/releases/ReleaseRow.jsx:
--------------------------------------------------------------------------------
1 | import { Box, Flex, Grid, Link, Tag, Text, useColorModeValue } from "@chakra-ui/react";
2 | import NextLink from "next/link";
3 | import { IoIosNuclear } from "react-icons/io";
4 | import TimeAgo from "timeago-react";
5 | import { getSection } from "../../utils/classify";
6 | import CategoryBadge from "./CategoryBadge";
7 | import CopyButton from "./CopyButon";
8 |
9 | const ReleaseRow = ({ release }) => {
10 | const colorvalues = useColorModeValue(
11 | {
12 | borderColor: "gray.200",
13 | hoverColor: "gray.200",
14 | gray: "gray.500",
15 | tagBg: "gray.300",
16 | },
17 | {
18 | borderColor: "gray.800",
19 | hoverColor: "gray.800",
20 | gray: "gray.500",
21 | tagBg: "rgba(77, 209, 196, 0.1)",
22 | },
23 | );
24 |
25 | const section = getSection(release.name, release.section);
26 |
27 | return (
28 |
50 |
62 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 | {release.name}
79 |
80 |
81 |
82 |
83 |
84 | {!release.traces ? (
85 | No tracers yet
86 | ) : (
87 | release.traces.map((tr) => `#${tr.rank}\u00A0${tr.site}`).join(", ")
88 | )}
89 |
90 |
91 |
92 | {release.nukes && (
93 | `[${n.type}] ${n.reason}`).join("\r\n")}
95 | as={IoIosNuclear}
96 | color="orange.400"
97 | cursor="help"
98 | />
99 | )}
100 |
101 | {release.proof && (
102 |
103 | Proof
104 |
105 | )}
106 |
107 | {release.nfo?.length > 0 && (
108 |
109 |
110 | NFO
111 |
112 |
113 | )}
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
128 | {release.files ? release.files + "F" : "-"}
129 |
130 |
136 | {release.size ? release.size + " MB" : "-"}
137 |
138 |
139 |
140 | );
141 | };
142 |
143 | export default ReleaseRow;
144 |
--------------------------------------------------------------------------------
/components/search/SearchAdvanced.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | Alert,
3 | AlertIcon,
4 | FormControl,
5 | FormLabel,
6 | Input,
7 | SimpleGrid,
8 | } from "@chakra-ui/react";
9 |
10 | const SearchAdvanced = () => {
11 | return (
12 | <>
13 |
14 |
15 | Advanced search mode does not work yet. Check back later!
16 |
17 |
18 |
19 |
20 | Release name
21 |
22 |
23 |
24 | Release group
25 |
26 |
27 |
28 | Category
29 |
30 |
31 |
32 | Trace
33 |
34 |
35 |
36 | NFO
37 |
38 |
39 |
40 | Nukes
41 |
42 |
43 |
44 | Size
45 |
46 |
47 |
48 | Added
49 |
50 |
51 |
52 | >
53 | );
54 | };
55 |
56 | export default SearchAdvanced;
57 |
--------------------------------------------------------------------------------
/components/search/SearchSimple.jsx:
--------------------------------------------------------------------------------
1 | import { CloseIcon, SearchIcon } from "@chakra-ui/icons";
2 | import {
3 | Box,
4 | IconButton,
5 | Input,
6 | InputGroup,
7 | InputLeftElement,
8 | InputRightElement,
9 | } from "@chakra-ui/react";
10 | import React, { useCallback, useEffect, useRef } from "react";
11 | import { useDispatch, useSelector } from "react-redux";
12 | import { clearSimple, setSimpleSearch } from "../../redux/slices/searchSlice";
13 | import { searchSimple } from "../../redux/slices/searchSlice";
14 |
15 | function debounce(callback, delay) {
16 | let timeout;
17 | return function () {
18 | clearTimeout(timeout);
19 | timeout = setTimeout(callback, delay);
20 | };
21 | }
22 |
23 | const SearchSimple = () => {
24 | const dispatch = useDispatch();
25 | const simpleSearch = useSelector((s) => s.search.simpleSearch);
26 |
27 | const inputRef = useRef();
28 |
29 | const handleSearch = () => {
30 | dispatch(setSimpleSearch(inputRef.current.value));
31 | };
32 |
33 | const handleChange = useCallback(debounce(handleSearch, 400), []);
34 |
35 | useEffect(() => {
36 | if (simpleSearch) dispatch(searchSimple({ input: simpleSearch, page: 1 }));
37 | else handleClear();
38 | }, [simpleSearch]);
39 |
40 | const handleClear = () => {
41 | inputRef.current.value = "";
42 | dispatch(clearSimple());
43 | };
44 |
45 | return (
46 |
47 |
48 |
49 |
50 |
51 |
52 | {simpleSearch && (
53 |
54 | }
58 | aria-label="Clear input"
59 | onClick={handleClear}
60 | />
61 |
62 | )}
63 |
64 | setQuery(e.target.value)}
75 | onChange={handleChange}
76 | />
77 |
78 |
79 | );
80 | };
81 |
82 | export default SearchSimple;
83 |
--------------------------------------------------------------------------------
/components/search/SearchTabs.jsx:
--------------------------------------------------------------------------------
1 | import { Tab, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react";
2 | import React from "react";
3 | import SearchAdvanced from "./SearchAdvanced";
4 | import SearchSimple from "./SearchSimple";
5 |
6 | const SearchTabs = () => {
7 | return (
8 |
9 |
10 | Simple
11 | Advanced
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | );
24 | };
25 |
26 | export default SearchTabs;
27 |
--------------------------------------------------------------------------------
/components/search/Toolbar.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | Flex,
3 | FormControl,
4 | HStack,
5 | Switch,
6 | Text,
7 | useColorModeValue,
8 | } from "@chakra-ui/react";
9 | import { useSelector } from "react-redux";
10 | import ModalSubscribe from "../modals/ModalSubscribe";
11 |
12 | const Toolbar = () => {
13 | const took = useSelector((state) => state.search.took);
14 | const resultsCnt = useSelector((state) => state.search.resultsCnt);
15 | const colors = useColorModeValue({ label: "dark.bg" }, { label: "teal.300" });
16 |
17 | return (
18 |
19 |
20 |
21 |
22 |
23 |
30 | Live updates
31 |
32 |
33 |
34 |
35 | {resultsCnt > 0 && (
36 |
37 | {resultsCnt < 10000 ? resultsCnt : `>${resultsCnt}`} results found in {took} ms
38 |
39 | )}
40 |
41 | {resultsCnt === 0 && (
42 |
43 | No results found
44 |
45 | )}
46 |
47 | );
48 | };
49 |
50 | export default Toolbar;
51 |
--------------------------------------------------------------------------------
/context/SearchContext.jsx:
--------------------------------------------------------------------------------
1 | import { createContext } from "react";
2 |
3 | export function searchReducer(state, action) {
4 | switch (action.type) {
5 | case "SEARCH_START":
6 | return { loading: true, error: null, data: null };
7 | case "SEARCH_SUCCESS":
8 | return { loading: false, error: null, data: action.payload };
9 | case "SEARCH_FAIL":
10 | return { loading: false, error: action.payload, data: null };
11 | }
12 | }
13 |
14 | export const initialState = {
15 | data: null,
16 | error: null,
17 | loading: false,
18 | };
19 |
20 | export const SearchContext = createContext(null);
21 | export const StateContext = createContext(null);
22 |
--------------------------------------------------------------------------------
/hooks/useDebounce.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export const useDebounce = (value, timeout) => {
4 | const [state, setState] = useState(value);
5 |
6 | useEffect(() => {
7 | const handler = setTimeout(() => setState(value), timeout);
8 |
9 | return () => clearTimeout(handler);
10 | }, [value, timeout]);
11 |
12 | return state;
13 | };
14 |
--------------------------------------------------------------------------------
/hooks/useFetch.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { API_BASE } from "../utils/routes";
3 |
4 | // const useFetch = (initialUrl, initialParams = {}, auth = false) => {
5 | // const [url, updateUrl] = useState(initialUrl);
6 | // const [params, updateParams] = useState(initialParams);
7 | // const [data, setData] = useState(null);
8 | // const [isLoading, setIsLoading] = useState(false);
9 | // const [hasError, setHasError] = useState(false);
10 | // const [errorMessage, setErrorMessage] = useState("");
11 |
12 | // const queryString = Object.keys(params)
13 | // .map((p) => encodeURIComponent(p) + "=" + encodeURIComponent(params[p]))
14 | // .join("&");
15 |
16 | // useEffect(() => {
17 | // const fetchData = async () => {
18 | // setIsLoading(true);
19 | // try {
20 | // const response = await fetch(`${API_BASE}${url}${queryString}`, {
21 | // headers: makeHeaders(auth),
22 | // });
23 | // const result = await response.json();
24 | // if (response.ok) {
25 | // setData(result);
26 | // const token = response.headers.get("x-token");
27 | // const refreshToken = response.headers.get("x-refresh-token");
28 | // if (token && refreshToken) {
29 | // localStorage.setItem("auth", { token, refreshToken });
30 | // }
31 | // } else {
32 | // setHasError(true);
33 | // setErrorMessage(result);
34 | // }
35 | // } catch (err) {
36 | // setHasError(true);
37 | // setErrorMessage(err.message);
38 | // } finally {
39 | // setIsLoading(false);
40 | // }
41 | // };
42 | // fetchData();
43 | // }, [url, params]);
44 |
45 | // return {
46 | // data,
47 | // isLoading,
48 | // hasError,
49 | // errorMessage,
50 | // updateUrl,
51 | // updateParams,
52 | // };
53 | // };
54 |
55 | export const makeHeaders = (auth = false) => {
56 | if (!auth) {
57 | // normal header
58 | return { "Content-Type": "application/json" };
59 | }
60 | try {
61 | // include tokens in header
62 | const t = JSON.parse(localStorage.getItem("auth"));
63 | return {
64 | "Content-Type": "application/json",
65 | "x-token": t.token,
66 | "x-refresh-token": t.refreshToken,
67 | };
68 | } catch (error) {
69 | console.error(error);
70 | return {};
71 | }
72 | };
73 |
74 | // export default useFetch;
75 |
--------------------------------------------------------------------------------
/hooks/useLocalStorage.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export const useLocalStorage = (key, defaultValue) => {
4 | const stored = localStorage.getItem(key);
5 | const initial = stored ? JSON.parse(stored) : defaultValue;
6 | const [value, setValue] = useState(initial);
7 |
8 | useEffect(() => {
9 | localStorage.setItem(key, JSON.stringify(value));
10 | }, [key, value]);
11 |
12 | return [value, setValue];
13 | };
14 |
--------------------------------------------------------------------------------
/hooks/useSocket.js:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { useDispatch } from "react-redux";
3 | import io from "socket.io-client";
4 | import { addRelease, updateRelease } from "../redux/slices/releasesSlice";
5 | import { API_BASE } from "../utils/routes";
6 |
7 | export function useSocket() {
8 | const dispatch = useDispatch();
9 |
10 | useEffect(() => {
11 | const socket = io(API_BASE);
12 |
13 | // custom events from API
14 | socket.on("data_added", handleNew);
15 | socket.on("data_updated", handleUpdate);
16 |
17 | // Socket.IO default events
18 | socket.on("connect", () => console.log("Socket connected"));
19 | socket.on("disconnect", () => console.log("Socket disconnected"));
20 | socket.on("connect_error", (err) => console.log("Connection error:", err.message));
21 |
22 | return () => socket.disconnect();
23 | }, []);
24 |
25 | const handleNew = (payload) => {
26 | console.log("new:", payload);
27 | dispatch(addRelease(payload));
28 | };
29 |
30 | const handleUpdate = (payload) => {
31 | console.log("update:", payload);
32 | dispatch(updateRelease(payload));
33 | };
34 | }
35 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | const withBundleAnalyzer = require("@next/bundle-analyzer")({
2 | enabled: process.env.ANALYZE === "true",
3 | });
4 |
5 | module.exports = withBundleAnalyzer({
6 | i18n: {
7 | locales: ["en-US"],
8 | defaultLocale: "en-US",
9 | },
10 | });
11 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "predb-live",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "NEXT_PUBLIC_GIT_SHA=`git rev-parse --short HEAD` next dev",
7 | "build": "NEXT_PUBLIC_GIT_SHA=`git rev-parse --short HEAD` next build",
8 | "start": "next start",
9 | "anal": "ANALYZE=true next build",
10 | "lint": "next lint"
11 | },
12 | "dependencies": {
13 | "@chakra-ui/icons": "^1.0.15",
14 | "@chakra-ui/react": "^1.6.6",
15 | "@emotion/react": "^11.4.1",
16 | "@emotion/styled": "^11.3.0",
17 | "@reduxjs/toolkit": "^1.6.1",
18 | "focus-visible": "^5.2.0",
19 | "framer-motion": "^4.1.17",
20 | "lodash": "^4.17.21",
21 | "lodash.debounce": "^4.0.8",
22 | "next": "^11.1.0",
23 | "react": "17.0.2",
24 | "react-dom": "17.0.2",
25 | "react-icons": "^4.2.0",
26 | "react-redux": "^7.2.4",
27 | "socket.io-client": "^4.1.3",
28 | "timeago-react": "^3.0.3"
29 | },
30 | "devDependencies": {
31 | "@next/bundle-analyzer": "^11.1.0",
32 | "eslint": "7.32.0",
33 | "eslint-config-next": "11.1.0",
34 | "prettier": "^2.3.2"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/pages/_app.jsx:
--------------------------------------------------------------------------------
1 | import { ChakraProvider } from "@chakra-ui/react";
2 | import "focus-visible/dist/focus-visible";
3 | import Head from "next/head";
4 | import { Provider } from "react-redux";
5 | import store from "../redux/store";
6 | import { theme } from "../styles";
7 | import "../styles/global.css";
8 |
9 | function MyApp({ Component, pageProps }) {
10 | return (
11 | <>
12 |
13 | PREdb | The Scene PRE & NFO database
14 |
18 |
19 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | >
32 | );
33 | }
34 |
35 | // Only uncomment this method if you have blocking data requirements for
36 | // every single page in your application. This disables the ability to
37 | // perform automatic static optimization, causing every page in your app to
38 | // be server-side rendered.
39 | //
40 | // MyApp.getInitialProps = async (appContext) => {
41 | // // calls page's `getInitialProps` and fills `appProps.pageProps`
42 | // const appProps = await App.getInitialProps(appContext);
43 | //
44 | // return { ...appProps }
45 | // }
46 |
47 | export default MyApp;
48 |
--------------------------------------------------------------------------------
/pages/about.jsx:
--------------------------------------------------------------------------------
1 | import { Heading } from "@chakra-ui/react";
2 | import Layout from "../components/layout/Layout";
3 |
4 | const About = () => {
5 | return (
6 |
7 | About
8 |
9 | );
10 | };
11 |
12 | export default About;
13 |
--------------------------------------------------------------------------------
/pages/api/hello.js:
--------------------------------------------------------------------------------
1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
2 |
3 | export default (req, res) => {
4 | res.statusCode = 200;
5 | res.json({ name: "John Doe" });
6 | };
7 |
--------------------------------------------------------------------------------
/pages/index.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Layout from "../components/layout/Layout";
3 | import { Pagination } from "../components/releases/Pagination";
4 | import ReleaseList from "../components/releases/ReleaseList";
5 | import SearchTabs from "../components/search/SearchTabs";
6 | import Toolbar from "../components/search/Toolbar";
7 |
8 | const Home = () => {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | );
19 | };
20 |
21 | export default Home;
22 |
--------------------------------------------------------------------------------
/pages/login.jsx:
--------------------------------------------------------------------------------
1 | import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
2 | import {
3 | Alert,
4 | AlertIcon,
5 | Box,
6 | Button,
7 | Checkbox,
8 | Flex,
9 | FormControl,
10 | FormLabel,
11 | Heading,
12 | HStack,
13 | IconButton,
14 | Input,
15 | InputGroup,
16 | InputRightElement,
17 | Link,
18 | Stack,
19 | Text,
20 | useColorModeValue,
21 | } from "@chakra-ui/react";
22 | import { useRouter } from "next/dist/client/router";
23 | import NextLink from "next/link";
24 | import { useEffect, useState } from "react";
25 | import { FiLogIn } from "react-icons/fi";
26 | import { useDispatch, useSelector } from "react-redux";
27 | import Layout from "../components/layout/Layout";
28 | import { login } from "../redux/slices/authSlice";
29 | import { addToast } from "../redux/slices/toastSlice";
30 |
31 | const Login = () => {
32 | const [showPassword, setShowPassword] = useState(false);
33 | const [credentials, setCredentials] = useState({
34 | email: "",
35 | password: "",
36 | remember: false,
37 | });
38 | const router = useRouter();
39 | const dispatch = useDispatch();
40 | const { user, loading, isAuthenticated } = useSelector((state) => state.auth);
41 |
42 | const colors = useColorModeValue({ panel: "gray.200" }, { panel: "whiteAlpha.50" });
43 |
44 | useEffect(() => {
45 | // redirect user to homepage after successful login
46 | if (isAuthenticated) {
47 | router.push("/");
48 | dispatch(addToast({ title: "Login successful!" }));
49 | }
50 | }, [isAuthenticated]);
51 |
52 | const handleInput = (e) => {
53 | const { name, value, type } = e.target;
54 | setCredentials((c) => ({ ...c, [name]: value }));
55 | };
56 |
57 | const handleSubmit = async (e) => {
58 | e.preventDefault();
59 | dispatch(login(credentials));
60 | };
61 |
62 | return (
63 |
64 | {router.query.confirmed === "1" && (
65 |
72 |
73 | Registration complete! You can log in now.
74 |
75 | )}
76 |
77 |
88 |
89 | Log in
90 |
91 | Email
92 |
93 |
101 |
102 |
103 |
104 |
105 | Password
106 |
107 |
116 |
117 | : }
121 | aria-label={showPassword ? "Hide" : "Show"}
122 | onClick={() => setShowPassword((s) => !s)}
123 | />
124 |
125 |
126 |
127 |
128 |
129 | Remember me
130 |
131 | Forgot password
132 |
133 |
134 |
135 | }
140 | type="submit"
141 | alignSelf="flex-end"
142 | isLoading={loading}
143 | >
144 | Log in
145 |
146 |
147 |
148 |
149 |
150 | OR
151 |
152 |
153 |
162 | Don't have an account yet?
163 |
164 |
165 |
168 |
169 |
170 |
171 | );
172 | };
173 |
174 | export default Login;
175 |
--------------------------------------------------------------------------------
/pages/logout.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { useDispatch } from "react-redux";
3 | import Layout from "../components/layout/Layout";
4 | import { logout } from "../redux/slices/authSlice";
5 |
6 | const Logout = () => {
7 | const dispatch = useDispatch();
8 |
9 | useEffect(() => {
10 | dispatch(logout());
11 | }, []);
12 | return ;
13 | };
14 |
15 | export default Logout;
16 |
--------------------------------------------------------------------------------
/pages/notifications.jsx:
--------------------------------------------------------------------------------
1 | import { Divider, Heading, ListItem, Stack, Text, UnorderedList } from "@chakra-ui/react";
2 | import { useEffect } from "react";
3 | import { useDispatch, useSelector } from "react-redux";
4 | import Layout from "../components/layout/Layout";
5 | import { NotificationCard } from "../components/notifications/NotificationCard";
6 | import { getNotifications } from "../redux/slices/notificationSlice";
7 |
8 | const Notifications = () => {
9 | const { notifications, loading, error } = useSelector((s) => s.notifications);
10 | const dispatch = useDispatch();
11 | const isAuthenticated = useSelector((s) => s.auth.isAuthenticated);
12 |
13 | useEffect(() => {
14 | if (!notifications && isAuthenticated) dispatch(getNotifications());
15 | }, [isAuthenticated]);
16 |
17 | return (
18 |
19 |
20 | Notifications
21 | {notifications && }
22 | {notifications?.length}
23 |
24 |
25 | {!isAuthenticated && (
26 |
27 |
28 | Log in to create & receive notifications on new matches for your searches!
29 |
30 |
31 |
32 | )}
33 |
34 | {isAuthenticated && notifications?.length === 0 && (
35 | <>
36 |
37 | You did not create any notification yet. Search for something on the release
38 | page, click Subscribe and get notified later when we find a new match!
39 |
40 | Available notification types:
41 |
42 | Email
43 | Webhook
44 | Web push notification
45 | Zapier integration
46 |
47 | >
48 | )}
49 |
50 | {error && JSON.stringify(error)}
51 |
52 | {notifications?.length > 0 && (
53 | <>
54 |
55 | You have {notifications.length} saved searches.
Here you can test each
56 | of them by pressing the button below which will trigger a dummy notification
57 | on the configured channels.
58 |
59 |
60 |
61 | {notifications.map((notif, i) => (
62 |
63 | ))}
64 |
65 | >
66 | )}
67 |
68 | );
69 | };
70 |
71 | const PromoText = () => (
72 | <>
73 | Available notifications on new matches:
74 |
75 | Email
76 | Webhook
77 | Web push notification
78 | Zapier integration
79 |
80 | >
81 | );
82 |
83 | export default Notifications;
84 |
--------------------------------------------------------------------------------
/pages/profile.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | Alert,
3 | Button,
4 | Flex,
5 | FormControl,
6 | FormHelperText,
7 | FormLabel,
8 | Heading,
9 | HStack,
10 | Icon,
11 | Input,
12 | NumberDecrementStepper,
13 | NumberIncrementStepper,
14 | NumberInput,
15 | NumberInputField,
16 | NumberInputStepper,
17 | Radio,
18 | RadioGroup,
19 | Stack,
20 | Text,
21 | } from "@chakra-ui/react";
22 | import { FaSave } from "react-icons/fa";
23 | import { FiDownload } from "react-icons/fi";
24 | import { GiSettingsKnobs } from "react-icons/gi";
25 | import Layout from "../components/layout/Layout";
26 | import { ActionGroup } from "../components/profile/ActionGroup";
27 | import { DeleteAccountModal } from "../components/profile/DeleteAccountModal";
28 |
29 | const Profile = () => {
30 | return (
31 |
32 | Profile page
33 |
34 |
35 | Profile settings do not work yet. Check back later!
36 |
37 |
38 |
39 |
49 |
50 |
51 | Release category badge
52 |
53 |
54 | Icons with text (default)
55 | Icons only
56 | Text only
57 | Dots with text
58 | Dots only
59 |
60 |
61 |
62 |
63 |
64 | Site theme
65 |
66 |
67 | Dark (default)
68 | Light
69 |
70 |
71 |
72 |
73 |
74 | Selected search method
75 |
76 |
77 | Simple (default)
78 | Advanced
79 |
80 |
81 |
82 |
83 |
84 | TMDB API key
85 |
89 |
90 | With the APi key set, you will see retail info about movies, TV-shows when
91 | available.
92 |
93 |
94 |
95 |
96 | Notification delay
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 | seconds
106 |
107 |
108 |
109 | }
111 | alignSelf="flex-start"
112 | variant="outline"
113 | colorScheme="green"
114 | >
115 | Save settings
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 | Current Password
124 |
125 |
126 |
127 | New password
128 |
129 |
130 |
131 | Confirm password
132 |
133 |
134 |
135 | Changing your password means all your login tokens will be invalidated.
136 |
137 | }
139 | alignSelf="flex-start"
140 | variant="outline"
141 | colorScheme="green"
142 | >
143 | Change password
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 | To comply with GDPR regulations, you can view all the data stored about you
153 | here.
154 |
155 | } w="180px">
156 | Download JSON
157 |
158 |
159 |
166 |
167 | Changed your mind? You can delete your account and all associated data here.
168 |
169 |
170 |
171 |
172 |
173 |
174 | );
175 | };
176 |
177 | export default Profile;
178 |
--------------------------------------------------------------------------------
/pages/register.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Button,
4 | FormControl,
5 | FormHelperText,
6 | FormLabel,
7 | Heading,
8 | HStack,
9 | IconButton,
10 | Input,
11 | InputGroup,
12 | InputRightElement,
13 | Stack,
14 | Text,
15 | useColorModeValue,
16 | useToast,
17 | } from "@chakra-ui/react";
18 | import NextLink from "next/link";
19 | import { useRouter } from "next/router";
20 | import { useEffect, useState } from "react";
21 | import { useDispatch, useSelector } from "react-redux";
22 | import Layout from "../components/layout/Layout";
23 | import { register } from "../redux/slices/authSlice";
24 | import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
25 |
26 | const initialState = {
27 | email: "",
28 | password: "",
29 | passwordConfirm: "",
30 | };
31 |
32 | const Register = () => {
33 | const toast = useToast();
34 | const router = useRouter();
35 | const dispatch = useDispatch();
36 | const [credentials, setCredentials] = useState(initialState);
37 | const [showPassword, setShowPassword] = useState(false);
38 | const { isAuthenticated, loading } = useSelector((state) => state.auth);
39 |
40 | const colors = useColorModeValue({ panel: "gray.200" }, { panel: "whiteAlpha.50" });
41 |
42 | useEffect(() => {
43 | if (isAuthenticated) router.push("/");
44 | }, [isAuthenticated]);
45 |
46 | const handleInput = (e) => {
47 | const { name, value } = e.target;
48 | setCredentials((c) => ({ ...c, [name]: value }));
49 | };
50 |
51 | const handleSubmit = async (e) => {
52 | e.preventDefault();
53 |
54 | dispatch(register(credentials));
55 |
56 | setCredentials({ ...initialState });
57 | document.querySelector("form").reset();
58 | };
59 |
60 | return (
61 |
62 |
73 |
74 | Register
75 |
76 |
77 | Email
78 |
79 |
87 |
88 |
89 | A confirmation email will be sent to this address
90 |
91 |
92 |
93 |
94 | Password
95 |
96 |
105 |
106 | : }
110 | aria-label="Clear input"
111 | onClick={() => setShowPassword((s) => !s)}
112 | />
113 |
114 |
115 |
116 |
117 |
118 | Confirm password
119 |
120 |
133 |
134 |
135 | : }
139 | aria-label={showPassword ? "Hide" : "Show"}
140 | onClick={() => setShowPassword((s) => !s)}
141 | />
142 |
143 |
144 |
145 |
146 |
157 |
158 |
159 |
160 |
161 | OR
162 |
163 |
164 |
173 | Already have an account?
174 |
175 |
178 |
179 |
180 |
181 | );
182 | };
183 |
184 | export default Register;
185 |
--------------------------------------------------------------------------------
/pages/release/[_id].jsx:
--------------------------------------------------------------------------------
1 | import { useColorModeValue } from "@chakra-ui/react";
2 | import { useRouter } from "next/router";
3 | import { useEffect, useState } from "react";
4 | import DetailsTable from "../../components/detailspage/DetailsTable";
5 | import Layout from "../../components/layout/Layout";
6 | import NFO from "../../components/detailspage/NFO";
7 | import RetailInfo_Movie_TMDB from "../../components/detailspage/RetailInfo_Movie_TMDB";
8 | import RetailInfo_TV_TMDB from "../../components/detailspage/RetailInfo_TV_TMDB";
9 | import { getSection } from "../../utils/classify";
10 | import { API_BASE } from "../../utils/routes";
11 | import { RetailInfo_Discogs } from "../../components/detailspage/RetailInfo_Discogs";
12 |
13 | const ReleasePage = () => {
14 | const [data, setData] = useState({
15 | release: null, // release details
16 | category: null, // parsed category
17 | });
18 | const [retailData, setRetailData] = useState();
19 | const borderColor = useColorModeValue("gray.300", "gray.700");
20 | const router = useRouter();
21 | const { _id } = router.query;
22 |
23 | console.log("data:", data);
24 | console.log("retailData:", retailData);
25 |
26 | useEffect(() => {
27 | if (_id) getData();
28 | }, [_id]);
29 |
30 | useEffect(() => {
31 | if (data.release && data.category) {
32 | console.log("calling getRetailData();", data.release, data.category);
33 | getRetailData(data);
34 | }
35 | }, [data]);
36 |
37 | const getData = async () => {
38 | // fetch release data
39 | const url = `${API_BASE}/api/data/details/_id/${_id}`;
40 | const res = await fetch(url);
41 | const details = await res.json();
42 |
43 | // parse section
44 | const section = getSection(details.name, details.section);
45 | console.log("section:", section);
46 | let cat;
47 | if (section.includes("TV")) cat = "series";
48 | else if (section.includes("MOVIE")) cat = "movie";
49 | else if (section.includes("MUSIC")) cat = "music";
50 |
51 | setData({ release: details, category: cat });
52 | };
53 |
54 | const getRetailData = async (d) => {
55 | const { release, category } = d;
56 | let url;
57 | if (category === "music") {
58 | url = `${API_BASE}/api/retail/discogs/${release.name}`;
59 | } else {
60 | url = `${API_BASE}/api/retail/tmdb/${category}/${release.name}`;
61 | }
62 | const res = await fetch(url);
63 | const rd = (await res.json()).data;
64 |
65 | setRetailData(rd);
66 | };
67 |
68 | if (!data.release) return ;
69 |
70 | return (
71 |
72 | {data && }
73 |
74 | {retailData && data.category === "series" && (
75 |
76 | )}
77 |
78 | {retailData && data.category === "movie" && (
79 |
80 | )}
81 |
82 | {retailData && data.category === "music" && (
83 |
84 | )}
85 | {/* */}
86 |
87 | {/* */}
88 |
89 | {data && }
90 |
91 | );
92 | };
93 |
94 | export default ReleasePage;
95 |
--------------------------------------------------------------------------------
/pages/restore.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Button,
4 | FormControl,
5 | FormHelperText,
6 | FormLabel,
7 | Heading,
8 | IconButton,
9 | Input,
10 | InputGroup,
11 | InputRightElement,
12 | Stack,
13 | useBoolean,
14 | useColorMode,
15 | useColorModeValue,
16 | } from "@chakra-ui/react";
17 | import { useRouter } from "next/router";
18 | import { useRef, useState } from "react";
19 | import Layout from "../components/layout/Layout";
20 | import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
21 | import { useDispatch } from "react-redux";
22 | import { API_BASE, API_ENDPOINT } from "../utils/routes";
23 | import { addErrorToast, addSuccessToast } from "../redux/slices/toastSlice";
24 | import { restore } from "../redux/slices/authSlice";
25 |
26 | const Restore = () => {
27 | const dispatch = useDispatch();
28 | const router = useRouter();
29 | const { token } = router.query;
30 | const [showPassword, setShowPassword] = useBoolean(false);
31 | const emailRef = useRef();
32 | const pwRef = useRef();
33 | const pwConfirmRef = useRef();
34 |
35 | const colors = useColorModeValue({ panel: "gray.200" }, { panel: "whiteAlpha.50" });
36 |
37 | const handleEmailSubmit = async (e) => {
38 | console.log(emailRef.current.value);
39 | e.preventDefault();
40 | dispatch(restore(emailRef.current.value));
41 | e.target.reset();
42 | };
43 |
44 | return (
45 |
46 | {!token && (
47 |
58 |
59 | Recover account
60 |
61 | Email
62 |
63 |
64 |
65 |
66 | Instructions to restore your account will be sent to this address
67 |
68 |
69 |
70 |
73 |
74 |
75 | )}
76 |
77 | {token && (
78 |
88 |
89 | Recover account
90 |
91 | New password
92 |
93 |
101 |
102 | : }
106 | aria-label="Clear input"
107 | onClick={setShowPassword.toggle}
108 | />
109 |
110 |
111 |
112 |
113 |
114 | Confirm new password
115 |
116 |
124 |
125 | : }
129 | aria-label={showPassword ? "Hide" : "Show"}
130 | onClick={setShowPassword.toggle}
131 | />
132 |
133 |
134 |
135 |
136 |
139 |
140 |
141 | )}
142 |
143 | );
144 | };
145 |
146 | export default Restore;
147 |
--------------------------------------------------------------------------------
/pages/rss.jsx:
--------------------------------------------------------------------------------
1 | import { API_BASE, API_ENDPOINT } from "../utils/routes";
2 |
3 | const getRssXml = (releases) => {
4 | const rssItemsXml = getReleasesXML(releases);
5 | return `
6 |
7 |
8 | PREdb.live RSS feed
9 | https://predb.live
10 | PRE and NFO database and notification service
11 | en
12 | ${rssItemsXml}
13 |
14 | `;
15 | };
16 |
17 | const getReleasesXML = (releases) => {
18 | let rssItemsXml = "";
19 |
20 | releases.forEach((release) => {
21 | const trace = release.traces
22 | ? release.traces.map((t) => t.site).join(",")
23 | : "No tracers yet";
24 |
25 | rssItemsXml += `
26 | -
27 | ${release.name}
28 | https://predb.live/release/${release._id}
29 | https://predb.live/release/${release._id}
30 | ${release.added}
31 | ${trace}
32 |
`;
33 | });
34 |
35 | return rssItemsXml;
36 | };
37 |
38 | export const getServerSideProps = async (context) => {
39 | const { res } = context;
40 | if (!res) return;
41 |
42 | const releaseResp = await fetch(API_BASE + API_ENDPOINT.RELEASES);
43 | const releases = await releaseResp.json();
44 |
45 | res.setHeader("Content-Type", "application/rss+xml");
46 | res.write(getRssXml(releases.data.values));
47 | res.end();
48 | return { props: {} };
49 | };
50 |
51 | const RSS = () => null;
52 |
53 | export default RSS;
54 |
--------------------------------------------------------------------------------
/pages/stats.jsx:
--------------------------------------------------------------------------------
1 | import {
2 | Heading,
3 | Spinner,
4 | Table,
5 | Tbody,
6 | Td,
7 | Text,
8 | Th,
9 | Thead,
10 | Tr,
11 | } from "@chakra-ui/react";
12 | import { useEffect } from "react";
13 | import { useDispatch, useSelector } from "react-redux";
14 | import TimeAgo from "timeago-react";
15 | import Layout from "../components/layout/Layout";
16 | import { getStats } from "../redux/slices/statsSlice";
17 |
18 | const Stats = () => {
19 | const { stats, error, loading } = useSelector((s) => s.stats);
20 | const dispatch = useDispatch();
21 |
22 | useEffect(() => {
23 | if (!stats) dispatch(getStats());
24 | }, []);
25 |
26 | return (
27 |
28 | Statistics
29 | Some fancy graphs coming soon...
30 |
31 | {!stats || loading ? (
32 |
33 | ) : (
34 |
35 |
36 |
37 | Metric |
38 | value |
39 |
40 |
41 |
42 |
43 | Users |
44 | {stats.userCount} |
45 |
46 |
47 | Pending registrations |
48 | {stats.pendingUsersCount} |
49 |
50 |
51 | Last registration |
52 |
53 |
57 | |
58 |
59 |
60 | Active notifications |
61 | {stats.notificationCount} |
62 |
63 |
64 | Total releases |
65 | {stats.releaseCount.toLocaleString()} |
66 |
67 |
68 | Releases with at least 1 tracer |
69 | {stats.releaseWithTraceCount.toLocaleString()} |
70 |
71 |
72 | NFO files |
73 | {stats.nfoCount.toLocaleString()} |
74 |
75 |
76 |
77 | )}
78 |
79 | );
80 | };
81 |
82 | export default Stats;
83 |
--------------------------------------------------------------------------------
/public/dolittle.2020.multi.complete.uhd.bluray-orca-proof.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/la55u/predb-frontend/b77edc2a19362b100a7dbd18aad39b91e3777ad6/public/dolittle.2020.multi.complete.uhd.bluray-orca-proof.jpg
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/la55u/predb-frontend/b77edc2a19362b100a7dbd18aad39b91e3777ad6/public/favicon.ico
--------------------------------------------------------------------------------
/public/movie-placeholder-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/la55u/predb-frontend/b77edc2a19362b100a7dbd18aad39b91e3777ad6/public/movie-placeholder-dark.png
--------------------------------------------------------------------------------
/public/movie-placeholder-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/la55u/predb-frontend/b77edc2a19362b100a7dbd18aad39b91e3777ad6/public/movie-placeholder-light.png
--------------------------------------------------------------------------------
/public/settings-knobs.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/redux/slices/authSlice.js:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
2 | import { makeHeaders } from "../../hooks/useFetch";
3 | import { authFetch } from "../../utils/helpers";
4 | import { API_BASE, API_ENDPOINT } from "../../utils/routes";
5 | import { addErrorToast, addSuccessToast } from "./toastSlice";
6 |
7 | export const login = createAsyncThunk("auth/login", async (data, thunkAPI) => {
8 | try {
9 | const response = await fetch(API_BASE + API_ENDPOINT.LOGIN, {
10 | method: "POST",
11 | body: JSON.stringify(data),
12 | headers: {
13 | "Content-Type": "application/json",
14 | },
15 | });
16 |
17 | if (!response.ok) {
18 | const error = await response.json();
19 | return thunkAPI.rejectWithValue({ error: error.errors });
20 | }
21 | const json = await response.json();
22 | console.log("login json:", json);
23 | return json;
24 | } catch (error) {
25 | return thunkAPI.rejectWithValue({ error: error.message });
26 | }
27 | });
28 |
29 | export const register = createAsyncThunk("auth/register", async (data, thunkAPI) => {
30 | try {
31 | const res = await fetch(API_BASE + API_ENDPOINT.REGISTER, {
32 | method: "POST",
33 | headers: { "Content-Type": "application/json" },
34 | body: JSON.stringify(data),
35 | });
36 | const json = await res.json();
37 | if (!res.ok) {
38 | thunkAPI.dispatch(
39 | addErrorToast({
40 | title: "Registration failed",
41 | description: json.message ?? "Unknown error",
42 | }),
43 | );
44 | return thunkAPI.rejectWithValue({ error: "Error during registration" });
45 | }
46 | thunkAPI.dispatch(
47 | addSuccessToast({
48 | title: "Registration started",
49 | description: "A confirmation email was sent to your email address.",
50 | }),
51 | );
52 | return json;
53 | } catch (error) {
54 | return thunkAPI.rejectWithValue({ error: error.message });
55 | }
56 | });
57 |
58 | export const deleteAccount = createAsyncThunk(
59 | "auth/deleteAccount",
60 | async (_, thunkAPI) => {
61 | const res = await fetch(API_BASE + API_ENDPOINT.DELETE, {
62 | method: "DELETE",
63 | headers: makeHeaders(true),
64 | });
65 | if (!res.ok) {
66 | thunkAPI.dispatch(
67 | addErrorToast({
68 | title: "Could not delete account",
69 | description: "Please try again later",
70 | }),
71 | );
72 | return thunkAPI.rejectWithValue({ error: "Could not delete account!" });
73 | }
74 | thunkAPI.dispatch(addSuccessToast({ title: "Account deleted" }));
75 | },
76 | );
77 |
78 | export const getMe = createAsyncThunk("auth/getMe", async (_, thunkAPI) => {
79 | try {
80 | const res = await fetch(API_BASE + API_ENDPOINT.ME, {
81 | headers: makeHeaders(true),
82 | });
83 | if (!res.ok) {
84 | return thunkAPI.rejectWithValue({ error: "Error getting user details!" });
85 | }
86 | const json = await res.json();
87 | return json;
88 | } catch (error) {
89 | return thunkAPI.rejectWithValue({ error: error.message });
90 | }
91 | });
92 |
93 | export const restore = createAsyncThunk("auth/restore", async (email, { dispatch }) => {
94 | try {
95 | const res = await fetch(API_BASE + API_ENDPOINT.RESTORE, {
96 | method: "POST",
97 | headers: makeHeaders(),
98 | body: JSON.stringify({ email }),
99 | });
100 |
101 | if (!res.ok) {
102 | const json = await res.json();
103 | dispatch(addErrorToast({ title: json.message }));
104 | return thunkAPI.rejectWithValue({ error: "Error getting user details!" });
105 | }
106 | addSuccessToast({ title: "Email sent" });
107 | } catch (error) {
108 | console.error(error);
109 | thunkAPI.rejectWithValue({ error: error.message });
110 | }
111 | });
112 |
113 | const authSlice = createSlice({
114 | name: "auth",
115 | initialState: {
116 | loading: false,
117 | error: null,
118 | user: null,
119 | isAuthenticated: false,
120 | },
121 | reducers: {
122 | logout: () => {
123 | localStorage.removeItem("auth");
124 | window.location.href = "/";
125 | },
126 | setAuthenticated: (state) => {
127 | state.isAuthenticated = true;
128 | },
129 | },
130 | extraReducers: {
131 | [login.pending]: (state) => {
132 | state.loading = true;
133 | },
134 | [login.fulfilled]: (state, action) => {
135 | state.loading = false;
136 | state.isAuthenticated = true;
137 | localStorage.setItem("auth", JSON.stringify(action.payload));
138 | },
139 | [login.rejected]: (state, action) => {
140 | state.loading = false;
141 | state.error = action.payload.error;
142 | state.isAuthenticated = false;
143 | },
144 |
145 | [register.pending]: (state) => {
146 | state.loading = true;
147 | },
148 | [register.fulfilled]: (state) => {
149 | state.loading = false;
150 | },
151 | [register.rejected]: (state, action) => {
152 | state.loading = false;
153 | state.error = action.payload.error;
154 | },
155 |
156 | [getMe.pending]: (state) => {
157 | state.loading = true;
158 | },
159 | [getMe.fulfilled]: (state, action) => {
160 | state.loading = false;
161 | state.user = action.payload;
162 | },
163 | [getMe.rejected]: (state, action) => {
164 | state.loading = false;
165 | state.error = action.payload.error;
166 | },
167 |
168 | [deleteAccount.pending]: (state) => {
169 | state.loading = true;
170 | },
171 | [deleteAccount.fulfilled]: (state) => {
172 | state.loading = false;
173 | localStorage.clear();
174 | window.location.href = "/";
175 | },
176 | [deleteAccount.rejected]: (state, action) => {
177 | state.loading = false;
178 | state.error = action.payload.error;
179 | },
180 | },
181 | });
182 |
183 | export const { setAuthenticated, logout } = authSlice.actions;
184 | export default authSlice.reducer;
185 |
--------------------------------------------------------------------------------
/redux/slices/notificationSlice.js:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
2 | import { authFetch } from "../../utils/helpers";
3 | import { API_ENDPOINT } from "../../utils/routes";
4 | import { addErrorToast, addSuccessToast } from "./toastSlice";
5 |
6 | export const getNotifications = createAsyncThunk(
7 | "notifications/getNotifications",
8 | async (_, thunkAPI) => {
9 | try {
10 | const data = await authFetch(API_ENDPOINT.NOTIFICATIONS);
11 | return data;
12 | } catch (error) {
13 | return thunkAPI.rejectWithValue({ error: error.message });
14 | }
15 |
16 | // .then((json) => {
17 | // //
18 | // console.log("getNotifications:", json);
19 | // return json;
20 | // })
21 | // .catch((error) => {
22 | // return thunkAPI.rejectWithValue({ error: error.message });
23 | // });
24 | },
25 | );
26 |
27 | export const createNotification = createAsyncThunk(
28 | "notifications/createNotification",
29 | async (data, thunkAPI) => {
30 | try {
31 | const json = await authFetch(API_ENDPOINT.NOTIFICATIONS, {
32 | method: "POST",
33 | body: JSON.stringify(data),
34 | });
35 | thunkAPI.dispatch(
36 | addSuccessToast({
37 | title: "Notification saved",
38 | description: "You will be notified when a new match is found",
39 | }),
40 | );
41 | } catch (error) {
42 | thunkAPI.dispatch(
43 | addErrorToast({
44 | title: "Error saving notification",
45 | description: "Please try again later",
46 | }),
47 | );
48 | return thunkAPI.rejectWithValue({ error: error.message });
49 | }
50 | },
51 | );
52 |
53 | export const removeNotification = createAsyncThunk(
54 | "notifications/removeNotification",
55 | async (id, thunkAPI) => {
56 | try {
57 | const json = await authFetch(`${API_ENDPOINT.NOTIFICATIONS}/${id}`, {
58 | method: "DELETE",
59 | });
60 | thunkAPI.dispatch(addSuccessToast({ title: "Notification removed" }));
61 | } catch (error) {
62 | thunkAPI.dispatch(
63 | addErrorToast({
64 | title: "Error removing notification",
65 | description: error.message,
66 | }),
67 | );
68 | return thunkAPI.rejectWithValue({ error: error.message });
69 | }
70 | },
71 | );
72 |
73 | const notificationSlice = createSlice({
74 | name: "notification",
75 | initialState: {
76 | loading: false,
77 | error: null,
78 | notifications: null,
79 | },
80 | reducers: {},
81 | extraReducers: {
82 | [getNotifications.pending]: (state) => {
83 | state.loading = true;
84 | },
85 | [getNotifications.fulfilled]: (state, action) => {
86 | state.loading = false;
87 | state.notifications = action.payload;
88 | console.log("payload:", action.payload);
89 | },
90 | [getNotifications.rejected]: (state, action) => {
91 | state.loading = false;
92 | state.error = action.payload.error;
93 | },
94 |
95 | [createNotification.pending]: (state) => {
96 | state.loading = true;
97 | },
98 | [createNotification.fulfilled]: (state, action) => {
99 | state.loading = false;
100 | state.notifications = action.payload;
101 | },
102 | [createNotification.rejected]: (state, action) => {
103 | state.loading = false;
104 | state.error = action.payload.error;
105 | },
106 | },
107 | });
108 |
109 | export default notificationSlice.reducer;
110 |
--------------------------------------------------------------------------------
/redux/slices/releasesSlice.js:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
2 | import { API_BASE, API_ENDPOINT } from "../../utils/routes";
3 |
4 | export const getCount = createAsyncThunk("releases/getCount", async (_, thunkAPI) => {
5 | try {
6 | const res = await fetch(API_BASE + API_ENDPOINT.COUNT, {
7 | headers: {
8 | "Content-Type": "application/json",
9 | },
10 | });
11 | if (!res.ok) {
12 | return thunkAPI.rejectWithValue({ error: "Error getting total count" });
13 | }
14 | const json = await res.json();
15 | return json.data;
16 | } catch (error) {
17 | return thunkAPI.rejectWithValue({ error: error.message });
18 | }
19 | });
20 |
21 | export const getAllRelease = createAsyncThunk(
22 | "releases/getAllRelease",
23 | async (page, thunkAPI) => {
24 | console.log("getAllRelease async");
25 | try {
26 | const response = await fetch(
27 | `${API_BASE + API_ENDPOINT.RELEASES}?page=${page || 1}`,
28 | {
29 | headers: {
30 | "Content-Type": "application/json",
31 | },
32 | },
33 | );
34 |
35 | if (!response.ok) {
36 | const error = await response.json();
37 | return thunkAPI.rejectWithValue({ error: error.errors });
38 | }
39 | const json = await response.json();
40 | console.log("getAllRelease json response:", json);
41 | return json.data.values;
42 | } catch (error) {
43 | return thunkAPI.rejectWithValue({ error: error.message });
44 | }
45 | },
46 | );
47 |
48 | const releasesSlice = createSlice({
49 | name: "releases",
50 | initialState: {
51 | count: null,
52 | releaselist: [],
53 | loading: false,
54 | },
55 | reducers: {
56 | // standard reducer logic, with auto-generated action types per reducer
57 | addRelease: (state, action) => {
58 | state.releaselist = [
59 | { ...action.payload, new: true },
60 | ...state.releaselist.slice(0, state.releaselist.length - 1),
61 | ];
62 | state.count = state.count + 1;
63 | },
64 | updateRelease: (state, action) => {
65 | const idx = state.releaselist.findIndex((r) => r._id === action.payload._id);
66 | if (idx !== -1) {
67 | state.releaselist[idx] = action.payload;
68 | }
69 | },
70 | },
71 | extraReducers: {
72 | // Add reducers for additional action types here, and handle loading state as needed
73 |
74 | [getAllRelease.pending]: (state) => {
75 | state.releaselist = [];
76 | state.loading = true;
77 | },
78 | [getAllRelease.fulfilled]: (state, action) => {
79 | state.releaselist = action.payload;
80 | state.loading = false;
81 | },
82 | [getAllRelease.rejected]: (state, action) => {
83 | state.error = action.payload.error;
84 | },
85 |
86 | [getCount.pending]: (state) => {
87 | //
88 | },
89 | [getCount.fulfilled]: (state, action) => {
90 | state.count = action.payload;
91 | },
92 | [getCount.rejected]: (state, action) => {
93 | //
94 | },
95 | },
96 | });
97 |
98 | export const selectResults = (state) => state.results;
99 | export const selectReleaselist = (state) => state.releaselist;
100 |
101 | export const { addRelease, updateRelease } = releasesSlice.actions;
102 | export default releasesSlice.reducer;
103 |
--------------------------------------------------------------------------------
/redux/slices/searchSlice.js:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
2 | import { API_BASE, API_ENDPOINT } from "../../utils/routes";
3 |
4 | export const searchSimple = createAsyncThunk("search/simple", async (data, thunkAPI) => {
5 | try {
6 | const response = await fetch(API_BASE + API_ENDPOINT.SEARCH_SIMPLE, {
7 | method: "POST",
8 | body: JSON.stringify(data),
9 | headers: {
10 | "Content-Type": "application/json",
11 | },
12 | });
13 | if (!response.ok) {
14 | const error = await response.json();
15 | return thunkAPI.rejectWithValue({ error: error.message });
16 | }
17 | const resData = await response.json();
18 | return { ...resData.data, page: data.page };
19 | } catch (error) {
20 | return thunkAPI.rejectWithValue({ error: error.message });
21 | }
22 | });
23 |
24 | const searchSlice = createSlice({
25 | name: "search",
26 | initialState: {
27 | simpleSearch: null,
28 | loading: false,
29 | results: [],
30 | resultsCnt: null,
31 | took: 0, // time it took in ms to complete the request on the backend
32 | page: 1, // set by pagination component
33 | },
34 | reducers: {
35 | setSimpleSearch: (state, action) => {
36 | state.simpleSearch = action.payload;
37 | },
38 | clearSimple: (state, action) => {
39 | state.results = [];
40 | state.took = null; // time it took to perform the search on the backend (ms)
41 | state.simpleSearch = null; // input field value
42 | state.page = 1; // current page
43 | state.resultsCnt = null; // number of all results matching the search criteria
44 | },
45 | setPage: (state, action) => {
46 | state.page = action.payload;
47 | },
48 | },
49 | extraReducers: {
50 | [searchSimple.pending]: (state) => {
51 | state.results = [];
52 | state.resultsCnt = null;
53 | state.loading = true;
54 | state.took = null;
55 | },
56 | [searchSimple.fulfilled]: (state, action) => {
57 | const { results, took, took_mongo, values } = action.payload;
58 | state.results = values;
59 | state.took = took + took_mongo;
60 | state.resultsCnt = results;
61 | state.loading = false;
62 | state.page = action.payload.page;
63 | },
64 | [searchSimple.rejected]: (state, action) => {
65 | //console.log("action.payload:", action);
66 | //state.error = action.payload.error;
67 | },
68 | },
69 | });
70 |
71 | export const selectResults = (state) => state.results;
72 | export const selectReleaselist = (state) => state.releaselist;
73 |
74 | export const { setSimpleSearch, clearSimple, setPage } = searchSlice.actions;
75 | export default searchSlice.reducer;
76 |
--------------------------------------------------------------------------------
/redux/slices/statsSlice.js:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
2 | import { API_BASE, API_ENDPOINT } from "../../utils/routes";
3 |
4 | export const getStats = createAsyncThunk("stats/getStats", async (_, thunkApi) => {
5 | const res = await fetch(API_BASE + API_ENDPOINT.STATS);
6 | const data = await res.json();
7 | return data;
8 | });
9 |
10 | const statsSlice = createSlice({
11 | name: "stats",
12 | initialState: {
13 | loading: false,
14 | stats: null,
15 | error: null,
16 | },
17 | reducers: {},
18 | extraReducers: {
19 | [getStats.pending]: (state) => {
20 | state.loading = true;
21 | },
22 | [getStats.fulfilled]: (state, action) => {
23 | state.loading = false;
24 | state.stats = action.payload;
25 | },
26 | [getStats.rejected]: (state, action) => {
27 | state.loading = false;
28 | },
29 | },
30 | });
31 |
32 | export default statsSlice.reducer;
33 |
--------------------------------------------------------------------------------
/redux/slices/toastSlice.js:
--------------------------------------------------------------------------------
1 | import { createStandaloneToast } from "@chakra-ui/toast";
2 | import { createSlice } from "@reduxjs/toolkit";
3 | import { theme } from "../../styles";
4 |
5 | const toast = createStandaloneToast({ theme });
6 |
7 | const toastSlice = createSlice({
8 | name: "toast",
9 | initialState: {},
10 | reducers: {
11 | addToast: (state, action) => {
12 | const { title, description, status } = action.payload;
13 | toast({
14 | title,
15 | description,
16 | status,
17 | });
18 | },
19 | addSuccessToast: (state, action) => {
20 | const { title, description } = action.payload;
21 | toast({
22 | title,
23 | description,
24 | status: "success",
25 | duration: 5000,
26 | });
27 | },
28 | addErrorToast: (state, action) => {
29 | const { title, description } = action.payload;
30 | toast({
31 | title,
32 | description,
33 | status: "error",
34 | duration: 8000,
35 | });
36 | },
37 | },
38 | extraReducers: {},
39 | });
40 |
41 | export const { addToast, addSuccessToast, addErrorToast } = toastSlice.actions;
42 | export default toastSlice.reducer;
43 |
--------------------------------------------------------------------------------
/redux/store.js:
--------------------------------------------------------------------------------
1 | import { configureStore } from "@reduxjs/toolkit";
2 | import authReducer from "./slices/authSlice";
3 | import notificationSlice from "./slices/notificationSlice";
4 | import releasesReducer from "./slices/releasesSlice";
5 | import searchReducer from "./slices/searchSlice";
6 | import statsSlice from "./slices/statsSlice";
7 | import toastReducer from "./slices/toastSlice";
8 |
9 | export default configureStore({
10 | reducer: {
11 | releases: releasesReducer,
12 | search: searchReducer,
13 | auth: authReducer,
14 | toast: toastReducer,
15 | notifications: notificationSlice,
16 | stats: statsSlice,
17 | },
18 | devTools: true,
19 | });
20 |
--------------------------------------------------------------------------------
/styles/components/button.js:
--------------------------------------------------------------------------------
1 | const baseStyle = {
2 | borderRadius: "sm",
3 | };
4 |
5 | const variants = {
6 | solid: (props) => {
7 | const { colorScheme: c } = props;
8 | return {
9 | shadow: "md",
10 | // bg: `${c}.200`,
11 | };
12 | },
13 | };
14 |
15 | export default {
16 | baseStyle,
17 | variants,
18 | };
19 |
--------------------------------------------------------------------------------
/styles/components/container.js:
--------------------------------------------------------------------------------
1 | export default {
2 | baseStyle: {
3 | width: "full",
4 | maxW: "1100px",
5 | },
6 | variants: {
7 | fullwidth: {
8 | maxW: "1920px",
9 | },
10 | },
11 | };
12 |
--------------------------------------------------------------------------------
/styles/components/index.js:
--------------------------------------------------------------------------------
1 | import Button from "./button";
2 | import Container from "./container";
3 | import Input from "./input";
4 |
5 | export default {
6 | Container,
7 | Input,
8 | Button,
9 | };
10 |
--------------------------------------------------------------------------------
/styles/components/input.js:
--------------------------------------------------------------------------------
1 | import { mode } from "@chakra-ui/theme-tools";
2 |
3 | export default {
4 | variants: {
5 | filled: (props) => {
6 | return {
7 | field: {
8 | color: mode("light.col", "dark.col")(props),
9 | bg: mode("white", "whiteAlpha.50")(props),
10 | shadow: "sm",
11 | _placeholder: {
12 | color: mode("light.col", "dark.col")(props),
13 | opacity: 0.6,
14 | },
15 | _focus: {
16 | borderColor: mode("black", "teal.300")(props),
17 | bg: mode("white", "transparent")(props),
18 | },
19 | _hover: {
20 | bg: mode("gray.50", "whiteAlpha.100")(props),
21 | },
22 | },
23 | };
24 | },
25 | },
26 | defaultProps: {
27 | variant: "filled",
28 | },
29 | };
30 |
--------------------------------------------------------------------------------
/styles/foundations/colors.js:
--------------------------------------------------------------------------------
1 | export default {
2 | light: {
3 | bg: "rgb(237, 242, 247)",
4 | col: "gray.700",
5 | },
6 |
7 | dark: {
8 | bg: "#0b0b13",
9 | col: "gainsboro",
10 | },
11 | };
12 |
--------------------------------------------------------------------------------
/styles/foundations/index.js:
--------------------------------------------------------------------------------
1 | import colors from "./colors";
2 | import radii from "./radii";
3 | import typography from "./typography";
4 |
5 | export default {
6 | colors,
7 | radii,
8 | ...typography,
9 | };
10 |
--------------------------------------------------------------------------------
/styles/foundations/radii.js:
--------------------------------------------------------------------------------
1 | export default {
2 | sm: "3px",
3 | md: "5px",
4 | lg: "7px",
5 | };
6 |
--------------------------------------------------------------------------------
/styles/foundations/typography.js:
--------------------------------------------------------------------------------
1 | export default {
2 | fonts: {
3 | heading: `'Heebo', sans-serif`,
4 | body: `'Heebo', sans-serif`,
5 | mono: `SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace`,
6 | },
7 | };
8 |
--------------------------------------------------------------------------------
/styles/global.css:
--------------------------------------------------------------------------------
1 | @keyframes newitemfade {
2 | from {
3 | background: #4fd1c5;
4 | }
5 | to {
6 | background: transparent;
7 | }
8 | }
9 | .new-item {
10 | animation-name: newitemfade;
11 | animation-duration: 1.5s;
12 | }
13 |
14 | body {
15 | overflow-y: scroll;
16 | }
17 |
--------------------------------------------------------------------------------
/styles/index.js:
--------------------------------------------------------------------------------
1 | import { extendTheme } from "@chakra-ui/react";
2 | import components from "./components";
3 | import foundations from "./foundations";
4 | import styles from "./styles";
5 |
6 | const config = {
7 | useSystemColorMode: false,
8 | initialColorMode: "dark",
9 | cssVarPrefix: "chakra",
10 | };
11 |
12 | export const theme = extendTheme({
13 | ...foundations,
14 | components,
15 | styles,
16 | config,
17 | });
18 |
--------------------------------------------------------------------------------
/styles/styles.js:
--------------------------------------------------------------------------------
1 | import { mode } from "@chakra-ui/theme-tools";
2 |
3 | const styles = {
4 | global: (props) => ({
5 | body: {
6 | color: mode("light.col", "dark.col")(props),
7 | bg: mode("light.bg", "dark.bg")(props),
8 | transition: "background-color 0.2s",
9 | lineHeight: "base",
10 | },
11 | "::selection": {
12 | background: mode("black", "teal.300")(props),
13 | color: mode("teal.300", "black")(props),
14 | },
15 | "*::placeholder": {
16 | color: mode("gray.400", "whiteAlpha.400")(props),
17 | },
18 | "*, *::before, &::after": {
19 | borderColor: mode("gray.200", "whiteAlpha.300")(props),
20 | wordWrap: "break-word",
21 | },
22 | /* clears the ‘X’ from Chrome */
23 | "input[type='search']::-webkit-search-decoration, input[type='search']::-webkit-search-cancel-button, input[type='search']::-webkit-search-results-button, input[type='search']::-webkit-search-results-decoration":
24 | { appearance: "none" },
25 | }),
26 | };
27 |
28 | export default styles;
29 |
--------------------------------------------------------------------------------
/utils/classify.js:
--------------------------------------------------------------------------------
1 | // returns the normalized section (category) of a release
2 | export const getSection = (name, section) => {
3 | // XXX
4 | // avoid xXx movie
5 | if (/[.\-_]XXX|xxx[.\-_]/.test(name) || /XXX/i.test(section)) {
6 | if (/IMAGESET/i.test(name)) return SECTIONS.XXX_PICS;
7 | else return SECTIONS.XXX_VIDEO;
8 | }
9 |
10 | // SPORT
11 | if (
12 | /^(Moto2|Moto3|Tennis|Formula|UEFA|NFL|NBA|AFL|WNBA|NBL|NRL|WWE|WWF|Boxing|UFC|UEL|FA\.Cup|A-League|EPL|Fussball|Coppa\.Italia).*[.\-_][12][09]\d{2}[.\-_]/i.test(
13 | name
14 | ) &&
15 | /TV/.test(section)
16 | ) {
17 | return SECTIONS.SPORT;
18 | }
19 |
20 | // TV
21 | // S01E01 or S01D01 format
22 | if (/S\d+[ED]\d+/i.test(name) || /^TV/i.test(section)) {
23 | if (/720|1080|2160|[PI]/i.test(name)) return SECTIONS.TV_HD;
24 | else if (/S\d+D\d+/i.test(name)) return SECTIONS.TV_DISC; // S01D01
25 | return SECTIONS.TV_SD;
26 | }
27 |
28 | // MUSIC
29 | if (/MP3|FLAC|VINYL|MUSIC/i.test(section)) return SECTIONS.MUSIC_AUDIO;
30 |
31 | // GAMES
32 | // check for common consoles in name
33 | if (
34 | /NSW|PLAYSTATION|XBOX|PS4|PS3|PS2|NINTENDO|NDS|WIIU/i.test(name) ||
35 | /GAME|CONSOLE/i.test(section)
36 | ) {
37 | if (/NSW|NINTENDO/.test(name)) return SECTIONS.GAME_NINTENDO;
38 | else if (
39 | /PS2|PS3|PS4|PS5|PLAYSTATION/.test(name) ||
40 | /PS$|PS2|PS3|PS4|PS5|PLAYSTATION/.test(section)
41 | )
42 | return SECTIONS.GAME_PS;
43 | else if (/[.\-_]XBOX/i.test(name) || /[.\-_]XBOX/i.test(section))
44 | return SECTIONS.GAME_XBOX;
45 |
46 | return SECTIONS.GAME_PC;
47 | }
48 |
49 | // BOOK
50 | if (
51 | /[.\-_]E?BOOK[.\-_]|MOBI|AZW3|EPUB|MAGAZINE/i.test(name) &&
52 | /BOOK/i.test(section)
53 | ) {
54 | return SECTIONS.EBOOK;
55 | }
56 |
57 | // TUTORIAL
58 | if (
59 | /TUTORIAL|BOOKWARE|Udemy|Lynda|PluralSight|EggHead|LinkedIn|SkillShare|^Sonic.Academy|Sitepoint|^PACKT|OREILLY/i.test(
60 | name
61 | )
62 | ) {
63 | return SECTIONS.TUTORIAL;
64 | }
65 |
66 | // APPS
67 | if (/APP|PRE|0/.test(section)) {
68 | if (/LINUX/i.test(name) || /LINUX/i.test(section))
69 | return SECTIONS.APP_LINUX;
70 | else if (/MAC/i.test(name) || /MAC/i.test(section)) return SECTIONS.APP_MAC;
71 | else if (
72 | /Android|Mobile|IOS/i.test(name) ||
73 | /Android|Mobile|IOS/i.test(section)
74 | )
75 | return SECTIONS.APP_MOBILE;
76 | return SECTIONS.APP_WIN;
77 | }
78 |
79 | // MOVIE
80 | // name must contain a year
81 | if (/[.\-_][12][09]\d{2}[.\-_]/.test(name)) {
82 | if (/720|1080|2160[PI]/i.test(name)) return SECTIONS.MOVIE_HD;
83 | else if (/COMPLETE|PAL|NTSC/i.test(name)) return SECTIONS.MOVIE_DICS;
84 | return SECTIONS.MOVIE_SD;
85 | }
86 |
87 | // EVERYTHING ELSE
88 | return SECTIONS.UNKNOWN;
89 | };
90 |
91 | export const SECTIONS = {
92 | TV_HD: "TV_HD",
93 | TV_SD: "TV_SD",
94 | TV_DISC: "TV_DISC",
95 |
96 | MOVIE_HD: "MOVIE_HD",
97 | MOVIE_SD: "MOVIE_SD",
98 | MOVIE_DICS: "MOVIE_DICS",
99 |
100 | GAME_PC: "GAME_PC",
101 | GAME_XBOX: "GAME_XBOX",
102 | GAME_PS: "GAME_PS",
103 | GAME_NINTENDO: "GAME_NINTENDO",
104 |
105 | MUSIC_AUDIO: "MUSIC_AUDIO",
106 | MUSIC_VIDEO: "MUSIC_VIDEO",
107 | MUSIC_DICS: "MUSIC_DICS",
108 |
109 | APP_WIN: "APP_WIN",
110 | APP_LINUX: "APP_LINUX",
111 | APP_MAC: "APP_MAC",
112 | APP_MOBILE: "APP_MOBILE",
113 |
114 | EBOOK: "EBOOK",
115 | AUDIOBOOK: "AUDIOBOOK",
116 |
117 | XXX_PICS: "XXX_PICS",
118 | XXX_VIDEO: "XXX_VIDEO",
119 |
120 | SPORT: "SPORT",
121 | TUTORIAL: "TUTORIAL",
122 | UNKNOWN: "UNKNOWN",
123 | };
124 |
--------------------------------------------------------------------------------
/utils/helpers.js:
--------------------------------------------------------------------------------
1 | import { makeHeaders } from "../hooks/useFetch";
2 | import { API_BASE } from "./routes";
3 |
4 | export const authFetch = async (path, params = {}) => {
5 | return new Promise((resolve, reject) => {
6 | fetch(API_BASE + path, {
7 | headers: makeHeaders(true),
8 | ...params,
9 | })
10 | .then((response) => {
11 | if (response.ok) {
12 | const token = response.headers.get("x-token");
13 | const refreshToken = response.headers.get("x-refresh-token");
14 | if (token && refreshToken) {
15 | localStorage.setItem("auth", JSON.stringify({ token, refreshToken }));
16 | }
17 |
18 | if (response.status === 204) return {};
19 | return response.json();
20 | } else {
21 | throw Error(response.statusText);
22 | }
23 | })
24 | .then((json) => {
25 | return resolve(json);
26 | })
27 | .catch((error) => {
28 | console.error(error);
29 | return reject(error);
30 | });
31 | });
32 |
33 | // try {
34 | // fetch(API_BASE + path, {
35 | // headers: makeHeaders(true),
36 | // ...params,
37 | // }).then(res=> {
38 |
39 | // } res.json())
40 | // .then(json=>{
41 |
42 | // })
43 | // if (res.ok) {
44 | // const token = res.headers.get("x-token");
45 | // const refreshToken = res.headers.get("x-refresh-token");
46 | // if (token && refreshToken) {
47 | // localStorage.setItem("auth", { token, refreshToken });
48 | // }
49 | // const json = await res.json();
50 | // resolve(json);
51 | // }
52 | // } catch (error) {
53 | // reject({ error: JSON.stringify(error) });
54 | // console.error("Error during request:", error);
55 | // }
56 | };
57 |
--------------------------------------------------------------------------------
/utils/routes.js:
--------------------------------------------------------------------------------
1 | export const API_BASE = process.env.NEXT_PUBLIC_API_BASE;
2 |
3 | export const ROUTES = {
4 | home: "/",
5 | release: "/release/[rid]",
6 | login: "/login",
7 | register: "/register",
8 | restore: "/restore",
9 | };
10 |
11 | export const API_ENDPOINT = {
12 | RELEASES: "/api/data",
13 | COUNT: "/api/data/count",
14 | DOWNLOAD: "/api/data/download",
15 | STATS: "/api/stats",
16 |
17 | SEARCH_SIMPLE: "/api/search",
18 | SEARCH_ADVANCED: "/api/search/advanced",
19 |
20 | LOGIN: "/api/auth/login",
21 | LOGOUT: "/api/auth/logout",
22 | REGISTER: "/api/auth/register",
23 | RESTORE: "/api/auth/restore",
24 | RESTORE_PASSWORD: "/api/auth/restore/password",
25 | DELETE: "/api/auth/delete",
26 | ME: "/api/auth/me",
27 |
28 | NOTIFICATIONS: "/api/notifications",
29 | };
30 |
--------------------------------------------------------------------------------
/utils/sites.js:
--------------------------------------------------------------------------------
1 | export const SITES = {
2 | AL: "Acid-Lounge",
3 | AR: "AlphaRatio",
4 | FF: "FunFile",
5 | IPT: "IPTorrents",
6 | ME: "Milkie",
7 | nC: "nCore",
8 | PTM: "PreToMe",
9 | PTF: "PTFiles",
10 | RTT: "RevolutionTT",
11 | SBS: "SuperBits",
12 | SPC: "Speed.cd",
13 | TD: "TorrentDay",
14 | TL: "TorrentLeech",
15 | TN: "",
16 | TS: "TorrentSeeds",
17 | Tby: "TorrentBytes",
18 | XS: "XSpeeds",
19 | };
20 |
--------------------------------------------------------------------------------