├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .husky └── pre-commit ├── .lintstagedrc.js ├── .prettierignore ├── .prettierrc ├── .vscode └── extensions.json ├── LICENSE ├── README.md ├── next-env.d.ts ├── next.config.js ├── package.json ├── public └── favicon.ico ├── src ├── components │ ├── AppLoader.tsx │ ├── CollectionDetailModal.tsx │ ├── CollectionListItem.tsx │ ├── CollectionListItemLoader.tsx │ ├── Drawer.tsx │ ├── EmptyState.tsx │ ├── Header.tsx │ ├── Layout.tsx │ ├── ListItemActions.tsx │ ├── Loader.tsx │ ├── Logo.tsx │ ├── MotionBox.tsx │ ├── PlayList.tsx │ ├── PlayListItem.tsx │ ├── Player.tsx │ ├── PodcastDetailModal.tsx │ ├── PodcastListItem.tsx │ ├── PodcastListItemLoader.tsx │ ├── RankCollectionListItem.tsx │ ├── RankCollectionListItemLoader.tsx │ └── Search.tsx ├── constants │ ├── countries.ts │ └── message.ts ├── hooks │ ├── index.ts │ ├── useDebounce.ts │ └── useStore.ts ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── api │ │ └── collections │ │ │ ├── [id].ts │ │ │ ├── index.ts │ │ │ └── rank.ts │ ├── collections │ │ └── [id].tsx │ ├── index.tsx │ └── search │ │ └── index.tsx ├── services │ └── api.ts ├── stores │ ├── collectionStore.ts │ ├── playerStore.ts │ ├── rootStore.ts │ └── uiStore.ts ├── theme │ └── theme.ts ├── types │ └── global.d.ts └── utils │ ├── formatDuration.ts │ ├── formatMillisecondsToHms.ts │ ├── formatSecondsToHms.ts │ ├── index.ts │ └── normalizeString.ts ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = crlf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .husky 2 | .next 3 | .vscode 4 | build 5 | dist 6 | node_modules 7 | public 8 | .eslintrc.json 9 | .lintstagedrc.js 10 | next.config.js 11 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "prettier"], 3 | "rules": { 4 | "react-hooks/exhaustive-deps": "off" 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 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const eslintCommand = (filenames) => 4 | `next lint --fix --file ${filenames 5 | .map((f) => path.relative(process.cwd(), f)) 6 | .join(' --file ')}`; 7 | 8 | const prettierCommand = 'prettier --write'; 9 | 10 | const gitCommand = 'git add .'; 11 | 12 | module.exports = { 13 | '*.{js,jsx,ts,tsx}': [eslintCommand, prettierCommand, gitCommand], 14 | '*.{css,scss}': [prettierCommand, gitCommand], 15 | }; 16 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .husky 2 | .next 3 | .vscode 4 | build 5 | dist 6 | node_modules 7 | public 8 | .eslintrc.json 9 | .lintstagedrc.js 10 | next.config.js 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "tabWidth": 2, 5 | "semi": true, 6 | "jsxSingleQuote": true, 7 | "useTabs": false, 8 | "printWidth": 96, 9 | "endOfLine": "crlf", 10 | "arrowParens": "always", 11 | "importOrder": ["^@core/(.*)$", "^@server/(.*)$", "^@ui/(.*)$", "^[./]"], 12 | "importOrderSeparation": true, 13 | "importOrderSortSpecifiers": true 14 | } 15 | 16 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "editorconfig.editorconfig" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Nathan S. Santos 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PodJS 2 | 3 | PodJS is a open source podcast app built with Next.js, MobX and Chakra UI. 4 | 5 | ### Feel free to contribute opening an [issue](https://github.com/nathanssantos/podjs/issues) and/or a [pull request](https://github.com/nathanssantos/podjs/pulls). 6 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: false, 4 | }; 5 | 6 | module.exports = nextConfig; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "podjs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "prepare": "husky install" 11 | }, 12 | "dependencies": { 13 | "@chakra-ui/react": "^2.2.1", 14 | "@emotion/react": "^11", 15 | "@emotion/styled": "^11", 16 | "axios": "^0.27.2", 17 | "framer-motion": "^6", 18 | "mobx": "^6.6.0", 19 | "mobx-react": "^7.5.0", 20 | "next": "12.1.6", 21 | "nookies": "^2.5.2", 22 | "react": "18.1.0", 23 | "react-beautiful-dnd": "^13.1.0", 24 | "react-dom": "18.1.0", 25 | "react-h5-audio-player": "^3.8.4", 26 | "react-icons": "^4.4.0", 27 | "react-lazyload": "^3.2.0", 28 | "react-toastify": "^9.0.5", 29 | "rss-parser": "^3.12.0" 30 | }, 31 | "devDependencies": { 32 | "@trivago/prettier-plugin-sort-imports": "^3.2.0", 33 | "@types/cookie": "^0.5.1", 34 | "@types/node": "17.0.42", 35 | "@types/react": "18.0.12", 36 | "@types/react-beautiful-dnd": "^13.1.2", 37 | "@types/react-dom": "18.0.5", 38 | "@types/react-lazyload": "^3.2.0", 39 | "eslint": "8.17.0", 40 | "eslint-config-next": "12.1.6", 41 | "eslint-config-prettier": "^8.5.0", 42 | "husky": "^8.0.1", 43 | "lint-staged": "^13.0.2", 44 | "prettier": "^2.6.2", 45 | "typescript": "4.7.3" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nathanssantos/podjs/e1847715591a808957eecbfdb5ef5d39a6a5fc74/public/favicon.ico -------------------------------------------------------------------------------- /src/components/AppLoader.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Icon, Text } from '@chakra-ui/react'; 2 | import { motion } from 'framer-motion'; 3 | import { useEffect, useState } from 'react'; 4 | import { RiHeadphoneLine } from 'react-icons/ri'; 5 | 6 | import MotionBox from './MotionBox'; 7 | 8 | const AppLoader = () => { 9 | const [appIsLoading, setAppIsLoading] = useState(true); 10 | const [loaderIsVisible, setLoaderIsVisible] = useState(true); 11 | 12 | useEffect(() => { 13 | setTimeout(() => { 14 | setLoaderIsVisible(false); 15 | }, 1000); 16 | setTimeout(() => { 17 | setAppIsLoading(false); 18 | }, 1500); 19 | }, []); 20 | 21 | if (!appIsLoading) return null; 22 | 23 | return ( 24 | 32 | 43 | 44 | 45 | 46 | PodJS 47 | 48 | 49 | 50 | 51 | ); 52 | }; 53 | 54 | export default AppLoader; 55 | -------------------------------------------------------------------------------- /src/components/CollectionDetailModal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Badge, 3 | Button, 4 | Flex, 5 | Image, 6 | Modal, 7 | ModalBody, 8 | ModalCloseButton, 9 | ModalContent, 10 | ModalFooter, 11 | ModalHeader, 12 | ModalOverlay, 13 | Text, 14 | } from '@chakra-ui/react'; 15 | import { observer } from 'mobx-react'; 16 | import React, { useRef } from 'react'; 17 | 18 | import { useStore } from '../hooks'; 19 | import EmptyState from './EmptyState'; 20 | import Loader from './Loader'; 21 | 22 | const CollectionDetailModal = () => { 23 | const { uiStore, collectionStore } = useStore(); 24 | const btnRef = useRef(null); 25 | 26 | const { toggleCollectionModal, collectionDetailModalIsOpen } = uiStore; 27 | const { detail, detailStatus } = collectionStore; 28 | 29 | const renderDetail = () => { 30 | switch (detailStatus) { 31 | case 'fetching': { 32 | return ; 33 | } 34 | 35 | case 'empty': { 36 | return ; 37 | } 38 | 39 | case 'error': { 40 | return ; 41 | } 42 | 43 | case 'success': { 44 | if (detail) { 45 | const { 46 | artistName, 47 | collectionId, 48 | collectionName, 49 | artworkUrl100, 50 | artworkUrl600, 51 | description, 52 | managingEditor, 53 | language, 54 | copyright, 55 | lastBuildDate, 56 | primaryGenreName, 57 | genres, 58 | feedUrl, 59 | trackCount, 60 | country, 61 | items, 62 | } = detail; 63 | 64 | return ( 65 | <> 66 | 67 | 75 | {collectionName} 82 | 83 | 84 | 85 | 86 | {collectionName} 87 | 88 | 89 | {artistName} 90 | 91 | {!!description?.length && ( 92 | 93 | )} 94 | 95 | {primaryGenreName} 96 | 97 | 98 | {!!copyright?.length && ( 99 | 100 | {`${!copyright.includes('©') ? `© ${copyright}` : `${copyright}`}`} 101 | 102 | )} 103 | 104 | 105 | 106 | ); 107 | } 108 | } 109 | 110 | default: { 111 | return null; 112 | } 113 | } 114 | }; 115 | 116 | return ( 117 | toggleCollectionModal({ open: false })} 119 | finalFocusRef={btnRef} 120 | isOpen={collectionDetailModalIsOpen} 121 | scrollBehavior='outside' 122 | size='xl' 123 | > 124 | 125 | 126 | 127 | {!!detail?.collectionName?.length ? detail?.collectionName : ''} 128 | 129 | 130 | {renderDetail()} 131 | 132 | 133 | 134 | 135 | 136 | ); 137 | }; 138 | 139 | export default observer(CollectionDetailModal); 140 | -------------------------------------------------------------------------------- /src/components/CollectionListItem.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Badge, 3 | Box, 4 | Flex, 5 | Icon, 6 | IconButton, 7 | Image, 8 | Menu, 9 | MenuButton, 10 | MenuItem, 11 | MenuList, 12 | Spinner, 13 | Text, 14 | } from '@chakra-ui/react'; 15 | import { observer } from 'mobx-react'; 16 | import { useRouter } from 'next/router'; 17 | import { MouseEvent } from 'react'; 18 | import { RiInformationLine, RiMore2Fill, RiStarFill, RiStarLine } from 'react-icons/ri'; 19 | 20 | import { useStore } from '../hooks'; 21 | 22 | type CollectionListItemProps = { 23 | collection: Collection; 24 | }; 25 | 26 | const CollectionListItem = (props: CollectionListItemProps) => { 27 | const { collection } = props; 28 | const router = useRouter(); 29 | const { collectionStore, uiStore } = useStore(); 30 | 31 | const { 32 | collectionId, 33 | collectionName, 34 | artworkUrl600, 35 | artworkUrl100, 36 | primaryGenreName, 37 | genres, 38 | } = collection; 39 | const { 40 | detail, 41 | favorites, 42 | setDetail, 43 | addCollectionToFavorites, 44 | removeCollectionFromFavorites, 45 | } = collectionStore; 46 | const { toggleCollectionModal } = uiStore; 47 | 48 | const handleClick = () => { 49 | if (detail?.collectionId !== collectionId) setDetail(); 50 | router.push(`/collections/${collectionId}`); 51 | }; 52 | 53 | const addToFavorites = () => { 54 | addCollectionToFavorites(collection); 55 | }; 56 | 57 | const removeFromFavorites = () => { 58 | removeCollectionFromFavorites(collection); 59 | }; 60 | 61 | return ( 62 | 72 | 73 | {collectionName} 79 | 80 | 81 | } 82 | transition='all 150ms ease-in-out' 83 | /> 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | {collectionName} 92 | 93 | 94 | 95 | 96 | 97 | {primaryGenreName} 98 | 99 | 100 | 101 | 102 | 103 | 104 | ) => e.stopPropagation()}> 105 | 106 | } 110 | size='sm' 111 | variant='ghost' 112 | /> 113 | 120 | } 122 | onClick={() => toggleCollectionModal({ id: String(collectionId), open: true })} 123 | > 124 | Information 125 | 126 | {favorites.find((favorite) => favorite.collectionId === collectionId) ? ( 127 | } 129 | onClick={removeFromFavorites} 130 | > 131 | Remove from favorites 132 | 133 | ) : ( 134 | } 136 | onClick={addToFavorites} 137 | > 138 | Add to favorites 139 | 140 | )} 141 | 142 | 143 | 144 | 145 | 146 | ); 147 | }; 148 | 149 | export default observer(CollectionListItem); 150 | -------------------------------------------------------------------------------- /src/components/CollectionListItemLoader.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Flex, useColorMode } from '@chakra-ui/react'; 2 | import { motion } from 'framer-motion'; 3 | 4 | import MotionBox from './MotionBox'; 5 | 6 | type CollectionGridItemLoaderProps = { 7 | index: number; 8 | animate?: boolean; 9 | }; 10 | 11 | const CollectionListItemLoader = ({ 12 | index, 13 | animate = false, 14 | }: CollectionGridItemLoaderProps) => { 15 | const transition = { repeat: Infinity, duration: 1.5, delay: index * 0.15, ease: 'linear' }; 16 | const { colorMode } = useColorMode(); 17 | 18 | return ( 19 | 20 | 32 | 33 | 45 | 57 | 58 | 59 | ); 60 | }; 61 | 62 | export default CollectionListItemLoader; 63 | -------------------------------------------------------------------------------- /src/components/Drawer.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Drawer as CharkraDrawer, 4 | DrawerBody, 5 | DrawerCloseButton, 6 | DrawerContent, 7 | DrawerFooter, 8 | DrawerHeader, 9 | DrawerOverlay, 10 | Flex, 11 | useColorMode, 12 | } from '@chakra-ui/react'; 13 | import { observer } from 'mobx-react'; 14 | 15 | import { useStore } from '../hooks'; 16 | 17 | const Drawer = () => { 18 | const { uiStore } = useStore(); 19 | const { colorMode, toggleColorMode } = useColorMode(); 20 | const { drawerIsOpen, closeDrawer } = uiStore; 21 | 22 | return ( 23 | 24 | 25 | 26 | 27 | PodJS 28 | 29 | 30 | 31 | 32 | 33 | Set theme 34 | 37 | 38 | 39 | 40 | 41 | ); 42 | }; 43 | 44 | export default observer(Drawer); 45 | -------------------------------------------------------------------------------- /src/components/EmptyState.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Icon, Text } from '@chakra-ui/react'; 2 | import { observer } from 'mobx-react'; 3 | import { RiErrorWarningLine, RiSearchEyeLine } from 'react-icons/ri'; 4 | 5 | import { EMPTY_STATE, ERROR_STATE } from '../constants/message'; 6 | 7 | type EmptyStateProps = { 8 | variant?: 'not-found' | 'error'; 9 | text?: string; 10 | }; 11 | 12 | const renderContent = ({ variant = 'error', text }: EmptyStateProps) => { 13 | switch (variant) { 14 | case 'not-found': { 15 | return ( 16 | <> 17 | 18 | {!!text?.length ? text : EMPTY_STATE} 19 | 20 | ); 21 | } 22 | 23 | default: { 24 | return ( 25 | <> 26 | 27 | {!!text?.length ? text : ERROR_STATE} 28 | 29 | ); 30 | } 31 | } 32 | }; 33 | 34 | const EmptyState = (props: EmptyStateProps) => { 35 | const { variant, text } = props; 36 | 37 | return ( 38 | 47 | {renderContent({ variant, text })} 48 | 49 | ); 50 | }; 51 | 52 | export default observer(EmptyState); 53 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Flex, Icon, IconButton, useColorMode } from '@chakra-ui/react'; 2 | import { observer } from 'mobx-react'; 3 | import { useRouter } from 'next/router'; 4 | import { RiMenuLine } from 'react-icons/ri'; 5 | 6 | import { useStore } from '../hooks'; 7 | import Logo from './Logo'; 8 | 9 | const Header = () => { 10 | const { uiStore } = useStore(); 11 | const { colorMode } = useColorMode(); 12 | const router = useRouter(); 13 | 14 | const { openDrawer } = uiStore; 15 | 16 | return ( 17 | 30 | 39 | router.push('/')}> 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | ); 49 | }; 50 | 51 | export default observer(Header); 52 | -------------------------------------------------------------------------------- /src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { useDisclosure } from '@chakra-ui/react'; 2 | import { observer } from 'mobx-react'; 3 | import { ReactElement } from 'react'; 4 | 5 | import AppLoader from './AppLoader'; 6 | import CollectionDetailModal from './CollectionDetailModal'; 7 | import Drawer from './Drawer'; 8 | import Header from './Header'; 9 | import PlayList from './PlayList'; 10 | import Player from './Player'; 11 | 12 | type LayoutProps = { 13 | children: ReactElement | ReactElement[]; 14 | }; 15 | 16 | const Layout = ({ children }: LayoutProps) => { 17 | return ( 18 | <> 19 |
20 | {children} 21 | 22 | 23 | 24 | 25 | 26 | 27 | ); 28 | }; 29 | 30 | export default observer(Layout); 31 | -------------------------------------------------------------------------------- /src/components/ListItemActions.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Flex, Icon, IconButton, useColorMode } from '@chakra-ui/react'; 2 | import { RiHeartLine, RiInformationLine, RiPlayLine, RiPlayListAddLine } from 'react-icons/ri'; 3 | 4 | type ListItemActions = { 5 | showBtInfo?: boolean; 6 | showBtPlay?: boolean; 7 | showBtPlayListAdd?: boolean; 8 | showBtFavoriteAdd?: boolean; 9 | onClickInfo?: () => void; 10 | onClickPlay?: () => void; 11 | onClickPlaylistAdd?: () => void; 12 | onClickFavoriteAdd?: () => void; 13 | }; 14 | 15 | const ListItemActions = ({ 16 | showBtInfo = false, 17 | showBtPlay = false, 18 | showBtPlayListAdd = false, 19 | showBtFavoriteAdd = false, 20 | onClickInfo, 21 | onClickPlay, 22 | onClickPlaylistAdd, 23 | onClickFavoriteAdd, 24 | }: ListItemActions) => { 25 | const { colorMode } = useColorMode(); 26 | 27 | return ( 28 | 43 | 51 | {showBtPlay && ( 52 | 58 | 59 | 60 | )} 61 | {(showBtInfo || showBtPlayListAdd) && ( 62 | 63 | {showBtInfo && ( 64 | 70 | 71 | 72 | )} 73 | {showBtPlayListAdd && ( 74 | 80 | 81 | 82 | )} 83 | {showBtFavoriteAdd && ( 84 | 90 | 91 | 92 | )} 93 | 94 | )} 95 | 96 | ); 97 | }; 98 | 99 | export default ListItemActions; 100 | -------------------------------------------------------------------------------- /src/components/Loader.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, SimpleGrid, useColorMode } from '@chakra-ui/react'; 2 | import { motion } from 'framer-motion'; 3 | 4 | import CollectionGridItemLoader from './CollectionListItemLoader'; 5 | import MotionBox from './MotionBox'; 6 | import PodcastListItemLoader from './PodcastListItemLoader'; 7 | import RankCollectionItemLoader from './RankCollectionListItemLoader'; 8 | 9 | type LoaderProps = { 10 | variant?: 'grid' | 'list' | 'rank'; 11 | }; 12 | 13 | const Loader = ({ variant = 'grid' }: LoaderProps) => { 14 | const transition = { repeat: Infinity, duration: 1.5, ease: 'linear' }; 15 | const { colorMode } = useColorMode(); 16 | 17 | switch (variant) { 18 | case 'rank': { 19 | return ( 20 | 21 | {new Array(20).fill('').map((item, index) => ( 22 | 23 | ))} 24 | 25 | ); 26 | } 27 | 28 | case 'list': { 29 | return ( 30 | <> 31 | 40 | 49 | 59 | 60 | 61 | 62 | 73 | 84 | 95 | 106 | 107 | 117 | 118 | 119 | 120 | {new Array(6).fill('').map((item, index) => ( 121 | 122 | ))} 123 | 124 | 125 | ); 126 | } 127 | 128 | default: { 129 | return ( 130 | 131 | {new Array(21).fill('').map((item, index) => ( 132 | 133 | ))} 134 | 135 | ); 136 | } 137 | } 138 | }; 139 | 140 | export default Loader; 141 | -------------------------------------------------------------------------------- /src/components/Logo.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Icon, Text } from '@chakra-ui/react'; 2 | import { RiHeadphoneLine } from 'react-icons/ri'; 3 | 4 | type LogoProps = { 5 | size?: 'sm' | 'md' | 'lg'; 6 | }; 7 | 8 | const Logo = ({ size = 'sm' }: LogoProps) => { 9 | let iconSize = '28px'; 10 | let textSize = '20px'; 11 | let gap = 1; 12 | 13 | switch (size) { 14 | case 'lg': { 15 | iconSize = '56px'; 16 | textSize = '40px'; 17 | gap = 2; 18 | break; 19 | } 20 | 21 | default: { 22 | break; 23 | } 24 | } 25 | 26 | return ( 27 | 28 | 29 | 30 | PodJS 31 | 32 | 33 | ); 34 | }; 35 | 36 | export default Logo; 37 | -------------------------------------------------------------------------------- /src/components/MotionBox.tsx: -------------------------------------------------------------------------------- 1 | import { Box, type BoxProps } from '@chakra-ui/react'; 2 | import { motion } from 'framer-motion'; 3 | 4 | const MotionBox = motion>(Box); 5 | 6 | export default MotionBox; 7 | -------------------------------------------------------------------------------- /src/components/PlayList.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Drawer as CharkraDrawer, 3 | DrawerBody, 4 | DrawerCloseButton, 5 | DrawerContent, 6 | DrawerFooter, 7 | DrawerHeader, 8 | DrawerOverlay, 9 | Flex, 10 | } from '@chakra-ui/react'; 11 | import { observer } from 'mobx-react'; 12 | import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd'; 13 | 14 | import { useStore } from '../hooks'; 15 | import PlayListItem from './PlayListItem'; 16 | 17 | const PlayList = () => { 18 | const { playerStore, uiStore } = useStore(); 19 | 20 | const { playListIsOpen, closePlayList } = uiStore; 21 | const { playList, setPlayList } = playerStore; 22 | 23 | const reorder = (list: Podcast[], startIndex: number, endIndex: number) => { 24 | const result = list; 25 | const [removed] = result.splice(startIndex, 1); 26 | result.splice(endIndex, 0, removed); 27 | 28 | return result; 29 | }; 30 | 31 | const onDragEnd = (result: any) => { 32 | if (!result.destination) { 33 | return; 34 | } 35 | 36 | const newState = reorder(playList, result.source.index, result.destination.index); 37 | 38 | setPlayList(newState); 39 | }; 40 | 41 | return ( 42 | 43 | 44 | 45 | 46 | Playlist 47 | 48 | 49 | 50 | 51 | {(provided) => ( 52 | 53 | {playList.map((podcast, index) => ( 54 | 59 | {(provided) => ( 60 |
66 | 67 |
68 | )} 69 |
70 | ))} 71 | {provided.placeholder} 72 |
73 | )} 74 |
75 |
76 |
77 | 78 | 79 |
80 |
81 | ); 82 | }; 83 | 84 | export default observer(PlayList); 85 | -------------------------------------------------------------------------------- /src/components/PlayListItem.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Badge, 3 | Flex, 4 | Icon, 5 | IconButton, 6 | Image, 7 | Menu, 8 | MenuButton, 9 | MenuItem, 10 | MenuList, 11 | Text, 12 | useDisclosure, 13 | } from '@chakra-ui/react'; 14 | import { observer } from 'mobx-react'; 15 | import { RiDeleteBinLine, RiInformationLine, RiMore2Fill } from 'react-icons/ri'; 16 | 17 | import { useStore } from '../hooks'; 18 | import { formatDuration } from '../utils'; 19 | import PodcastDetailModal from './PodcastDetailModal'; 20 | 21 | type PlayListItemProps = { 22 | podcast: Podcast; 23 | }; 24 | 25 | const PlayListItem = (props: PlayListItemProps) => { 26 | const { podcast } = props; 27 | const { playerStore } = useStore(); 28 | const { isOpen, onOpen, onClose } = useDisclosure(); 29 | 30 | const { currentPodcast, setCurrentPodcast, removePodcastFromPlaylist } = playerStore; 31 | 32 | const { 33 | title, 34 | link, 35 | isoDate, 36 | enclosure: { url, length, type }, 37 | content, 38 | itunes: { summary, duration, image }, 39 | imageFallback, 40 | } = podcast; 41 | 42 | const playPodcast = () => { 43 | setCurrentPodcast({ ...podcast, imageFallback }); 44 | }; 45 | 46 | const removeFromPlayList = () => { 47 | removePodcastFromPlaylist(podcast); 48 | }; 49 | 50 | return ( 51 | 58 | 63 | 75 | {title} 83 | 84 | 85 | 91 | 92 | 104 | {title} 105 | 106 | 107 | 108 | 109 | } 113 | variant='ghost' 114 | size='sm' 115 | /> 116 | 123 | } 125 | onClick={onOpen} 126 | > 127 | Information 128 | 129 | } 131 | onClick={removeFromPlayList} 132 | > 133 | Remove from playlist 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 149 | 150 | {currentPodcast?.enclosure?.url === url && ( 151 | 158 | 159 | 160 | 170 | 171 | 172 | 182 | 183 | 184 | 194 | 195 | 196 | 206 | 207 | 208 | 209 | )} 210 | 211 | {formatDuration(duration)} 212 | 213 | {new Date(isoDate).toLocaleDateString('pt-BR')} 214 | 215 | 216 | 217 | 218 | 219 | 220 | ); 221 | }; 222 | 223 | export default observer(PlayListItem); 224 | -------------------------------------------------------------------------------- /src/components/Player.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Container, 3 | Flex, 4 | Icon, 5 | IconButton, 6 | Image, 7 | Text, 8 | useColorMode, 9 | useMediaQuery, 10 | } from '@chakra-ui/react'; 11 | import { motion } from 'framer-motion'; 12 | import { observer } from 'mobx-react'; 13 | import { ChangeEvent, useEffect } from 'react'; 14 | import AudioPlayer from 'react-h5-audio-player'; 15 | import { 16 | RiArrowUpLine, 17 | RiOrderPlayLine, 18 | RiPauseLine, 19 | RiPlayLine, 20 | RiRepeatLine, 21 | RiRewindLine, 22 | RiSkipBackLine, 23 | RiSkipForwardLine, 24 | RiSpeedLine, 25 | RiVolumeMuteLine, 26 | RiVolumeUpLine, 27 | } from 'react-icons/ri'; 28 | 29 | import { useStore } from '../hooks'; 30 | 31 | const Player = () => { 32 | const { playerStore, uiStore } = useStore(); 33 | const { colorMode } = useColorMode(); 34 | const [isLargerThan768] = useMediaQuery('(min-width: 768px)'); 35 | 36 | const { currentPodcast, next, previous, loadPlayerData, storeCurrentTime } = playerStore; 37 | const { playListIsOpen, drawerIsOpen } = uiStore; 38 | 39 | const scrollToTop = () => window.scrollTo(0, 0); 40 | 41 | const storeTime = (event: ChangeEvent | any) => { 42 | if (event?.target?.currentTime) storeCurrentTime(event.target.currentTime); 43 | }; 44 | 45 | useEffect(() => { 46 | loadPlayerData(); 47 | }, []); 48 | 49 | return ( 50 | 109 | 116 | {!playListIsOpen && !drawerIsOpen && ( 117 | 131 | 132 | 133 | )} 134 | 135 | {currentPodcast?.title} 143 | 144 | 145 | {!!currentPodcast?.title?.length && ( 146 | 152 | {currentPodcast.title} 153 | 154 | )} 155 | 156 | , 168 | pause: , 169 | rewind: , 170 | forward: , 171 | next: , 172 | previous: , 173 | volume: , 174 | volumeMute: , 175 | loop: , 176 | loopOff: , 177 | }} 178 | onClickPrevious={previous} 179 | onClickNext={next} 180 | onListen={storeTime} 181 | onEnded={next} 182 | /> 183 | 184 | 185 | 186 | ); 187 | }; 188 | 189 | export default observer(Player); 190 | -------------------------------------------------------------------------------- /src/components/PodcastDetailModal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Badge, 3 | Button, 4 | Flex, 5 | Image, 6 | Modal, 7 | ModalBody, 8 | ModalCloseButton, 9 | ModalContent, 10 | ModalFooter, 11 | ModalHeader, 12 | ModalOverlay, 13 | Text, 14 | } from '@chakra-ui/react'; 15 | import { observer } from 'mobx-react'; 16 | import React, { useRef } from 'react'; 17 | 18 | import { useStore } from '../hooks'; 19 | import { formatDuration } from '../utils'; 20 | 21 | type PodcastDetailModal = { 22 | podcast: Podcast; 23 | isOpen: boolean; 24 | onClose: () => void; 25 | }; 26 | 27 | const PodcastDetailModal = ({ podcast, isOpen, onClose }: PodcastDetailModal) => { 28 | const { uiStore, collectionStore } = useStore(); 29 | const btnRef = useRef(null); 30 | 31 | const { detail } = collectionStore; 32 | 33 | const { title, link, isoDate, enclosure, content, itunes } = podcast; 34 | 35 | return ( 36 | 43 | 44 | 45 | 46 | {!!detail?.collectionName?.length ? detail?.collectionName : ''} 47 | 48 | 49 | 50 | 51 | 52 | {title} 53 | 54 | 55 | 56 | 57 | {title} 58 | 59 | {!!detail?.artistName?.length && ( 60 | 61 | {detail.collectionName} 62 | 63 | )} 64 | 65 | 66 | {formatDuration(itunes.duration)} 67 | 68 | {new Date(isoDate).toLocaleDateString('pt-BR')} 69 | 70 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | ); 86 | }; 87 | 88 | export default observer(PodcastDetailModal); 89 | -------------------------------------------------------------------------------- /src/components/PodcastListItem.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Badge, 3 | Box, 4 | Flex, 5 | Icon, 6 | IconButton, 7 | Image, 8 | Menu, 9 | MenuButton, 10 | MenuItem, 11 | MenuList, 12 | Text, 13 | useColorMode, 14 | useDisclosure, 15 | } from '@chakra-ui/react'; 16 | import { observer } from 'mobx-react'; 17 | import { MouseEvent } from 'react'; 18 | import { 19 | RiDeleteBinLine, 20 | RiInformationLine, 21 | RiMenuLine, 22 | RiMore2Fill, 23 | RiPlayListAddLine, 24 | } from 'react-icons/ri'; 25 | 26 | import { useStore } from '../hooks'; 27 | import { formatDuration } from '../utils'; 28 | import PodcastDetailModal from './PodcastDetailModal'; 29 | 30 | type PodcastListItemProps = { 31 | podcast: Podcast; 32 | imageFallback: string; 33 | }; 34 | 35 | const PodcastListItem = (props: PodcastListItemProps) => { 36 | const { podcast, imageFallback } = props; 37 | const { playerStore } = useStore(); 38 | const { colorMode } = useColorMode(); 39 | const { isOpen, onOpen, onClose } = useDisclosure(); 40 | 41 | const { 42 | currentPodcast, 43 | playList, 44 | setCurrentPodcast, 45 | addPodcastToPlayList, 46 | removePodcastFromPlaylist, 47 | } = playerStore; 48 | 49 | const { title, link, isoDate, enclosure, content, itunes } = podcast; 50 | 51 | const playPodcast = () => { 52 | setCurrentPodcast({ ...podcast, imageFallback }); 53 | addPodcastToPlayList({ ...podcast, imageFallback }); 54 | }; 55 | 56 | const addToPlayList = () => { 57 | addPodcastToPlayList({ ...podcast, imageFallback }); 58 | if (!currentPodcast?.enclosure?.url) playPodcast(); 59 | }; 60 | 61 | const removeFromPlayList = () => { 62 | removePodcastFromPlaylist(podcast); 63 | }; 64 | 65 | const menu = ( 66 | 67 | } 71 | variant='ghost' 72 | size='sm' 73 | /> 74 | 81 | } onClick={onOpen}> 82 | Information 83 | 84 | {playList.find((podcast) => podcast.enclosure.url === enclosure.url) ? ( 85 | } 87 | onClick={removeFromPlayList} 88 | > 89 | Remove from playlist 90 | 91 | ) : ( 92 | } 94 | onClick={addToPlayList} 95 | > 96 | Add to playlist 97 | 98 | )} 99 | 100 | 101 | ); 102 | 103 | return ( 104 | 112 | 118 | 129 | {title} 137 | 138 | 139 | 140 | 141 | 147 | {title} 148 | 149 | ) => e.stopPropagation()}>{menu} 150 | 151 | 152 | 153 | {currentPodcast?.enclosure?.url === enclosure?.url && ( 154 | 161 | 162 | 163 | 173 | 174 | 175 | 185 | 186 | 187 | 197 | 198 | 199 | 209 | 210 | 211 | 212 | )} 213 | {!!itunes.duration && ( 214 | 215 | {formatDuration(itunes.duration)} 216 | 217 | )} 218 | {new Date(isoDate).toLocaleDateString('pt-BR')} 219 | 220 | 221 | 222 | 234 | 235 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | ); 250 | }; 251 | 252 | export default observer(PodcastListItem); 253 | -------------------------------------------------------------------------------- /src/components/PodcastListItemLoader.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, useColorMode } from '@chakra-ui/react'; 2 | import { motion } from 'framer-motion'; 3 | 4 | import MotionBox from './MotionBox'; 5 | 6 | type PodcastListItemLoaderProps = { 7 | index?: number; 8 | animate?: boolean; 9 | }; 10 | 11 | const PodcastListItemLoader = ({ index = 0, animate = false }: PodcastListItemLoaderProps) => { 12 | const { colorMode } = useColorMode(); 13 | const transition = { 14 | repeat: Infinity, 15 | duration: 1.5, 16 | ease: 'linear', 17 | delay: (index + 1) * 0.15, 18 | }; 19 | 20 | return ( 21 | 30 | 38 | 50 | 51 | 52 | 58 | 71 | 72 | 84 | 96 | 97 | 98 | 99 | 111 | 112 | 113 | 125 | 126 | 127 | 128 | 129 | ); 130 | }; 131 | 132 | export default PodcastListItemLoader; 133 | -------------------------------------------------------------------------------- /src/components/RankCollectionListItem.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Badge, 3 | Flex, 4 | Icon, 5 | IconButton, 6 | Image, 7 | Menu, 8 | MenuButton, 9 | MenuItem, 10 | MenuList, 11 | Spinner, 12 | Text, 13 | } from '@chakra-ui/react'; 14 | import { observer } from 'mobx-react'; 15 | import { useRouter } from 'next/router'; 16 | import { MouseEvent } from 'react'; 17 | import { RiInformationLine, RiMore2Fill, RiStarFill, RiStarLine } from 'react-icons/ri'; 18 | 19 | import { useStore } from '../hooks'; 20 | 21 | type RankCollectionListItemProps = { 22 | collection: Collection; 23 | index: number; 24 | }; 25 | 26 | const RankCollectionListItem = (props: RankCollectionListItemProps) => { 27 | const { collection, index } = props; 28 | const router = useRouter(); 29 | const { collectionStore, uiStore } = useStore(); 30 | 31 | const { 32 | collectionId, 33 | collectionName, 34 | artworkUrl600, 35 | artworkUrl100, 36 | primaryGenreName, 37 | genres, 38 | } = collection; 39 | const { 40 | detail, 41 | favorites, 42 | setDetail, 43 | addCollectionToFavorites, 44 | removeCollectionFromFavorites, 45 | } = collectionStore; 46 | const { toggleCollectionModal } = uiStore; 47 | 48 | const handleClick = () => { 49 | if (detail?.collectionId !== collectionId) setDetail(); 50 | router.push(`/collections/${collectionId}`); 51 | }; 52 | 53 | const addToFavorites = () => { 54 | addCollectionToFavorites(collection); 55 | }; 56 | 57 | const removeFromFavorites = () => { 58 | removeCollectionFromFavorites(collection); 59 | }; 60 | 61 | return ( 62 | 69 | 76 | 77 | {index + 1} 78 | 79 | 80 | 81 | 82 | 91 | {collectionName} 99 | 100 | 101 | } 102 | /> 103 | 104 | 105 | 106 | 107 | 108 | {collectionName} 109 | 110 | 111 | 112 | 113 | {primaryGenreName} 114 | 115 | 116 | ) => e.stopPropagation()} 119 | > 120 | 121 | } 125 | size='sm' 126 | variant='ghost' 127 | /> 128 | 135 | } 137 | onClick={() => 138 | toggleCollectionModal({ id: String(collectionId), open: true }) 139 | } 140 | > 141 | Information 142 | 143 | {favorites.find((favorite) => favorite.collectionId === collectionId) ? ( 144 | } 146 | onClick={removeFromFavorites} 147 | > 148 | Remove from favorites 149 | 150 | ) : ( 151 | } 153 | onClick={addToFavorites} 154 | > 155 | Add to favorites 156 | 157 | )} 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | ); 166 | }; 167 | 168 | export default observer(RankCollectionListItem); 169 | -------------------------------------------------------------------------------- /src/components/RankCollectionListItemLoader.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Text, useColorMode } from '@chakra-ui/react'; 2 | import { motion } from 'framer-motion'; 3 | 4 | import MotionBox from './MotionBox'; 5 | 6 | type RankCollectionItemLoaderProps = { 7 | index?: number; 8 | animate?: boolean; 9 | }; 10 | 11 | const RankCollectionItemLoader = ({ 12 | index = 0, 13 | animate = false, 14 | }: RankCollectionItemLoaderProps) => { 15 | const { colorMode } = useColorMode(); 16 | const transition = { 17 | repeat: Infinity, 18 | duration: 1.5, 19 | ease: 'linear', 20 | delay: (index + 1) * 0.15, 21 | }; 22 | 23 | return ( 24 | 25 | 39 | 44 | {index + 1} 45 | 46 | 47 | 48 | 56 | 68 | 69 | 70 | 71 | 84 | 85 | 97 | 98 | 99 | 100 | 101 | ); 102 | }; 103 | 104 | export default RankCollectionItemLoader; 105 | -------------------------------------------------------------------------------- /src/components/Search.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Flex, 3 | Icon, 4 | IconButton, 5 | Input, 6 | InputGroup, 7 | InputRightElement, 8 | Select, 9 | } from '@chakra-ui/react'; 10 | import { observer } from 'mobx-react'; 11 | import { useRouter } from 'next/router'; 12 | import { ChangeEvent, useEffect, useState } from 'react'; 13 | import { RiPlayListLine, RiSearchLine } from 'react-icons/ri'; 14 | 15 | import countries from '../constants/countries'; 16 | import { useDebounce, useStore } from '../hooks'; 17 | 18 | type SearchProps = { 19 | onChange?: (payload: { term: string; country: string }) => any; 20 | initialValue?: { term: string; country: string } | null; 21 | showCountry?: boolean; 22 | placeholder: string; 23 | redirectOnSearch?: boolean; 24 | }; 25 | 26 | const Search = ({ 27 | onChange, 28 | initialValue, 29 | placeholder, 30 | showCountry = false, 31 | redirectOnSearch = true, 32 | }: SearchProps) => { 33 | const router = useRouter(); 34 | const { playerStore, collectionStore, uiStore } = useStore(); 35 | const [mounted, setMounted] = useState(false); 36 | const [term, setTerm] = useState(initialValue?.term || ''); 37 | const [country, setCountry] = useState(initialValue?.country || ''); 38 | const debouncedTerm = useDebounce(term, 1000); 39 | 40 | const { term: termParam, country: countryParam } = router.query; 41 | 42 | const { openPlayList } = uiStore; 43 | const { playList } = playerStore; 44 | const { setSearchTerm, setSearchCountry } = collectionStore; 45 | 46 | const handleTermChange = (event: ChangeEvent) => { 47 | setTerm(event.target.value); 48 | }; 49 | 50 | const handleCountryChange = (event: ChangeEvent) => { 51 | setCountry(event.target.value); 52 | }; 53 | 54 | useEffect(() => { 55 | if (!mounted) return; 56 | 57 | window.scrollTo(0, 0); 58 | 59 | if (redirectOnSearch) { 60 | const query = {} as { term?: string; country?: string }; 61 | 62 | if (term?.length) query.term = term; 63 | if (country?.length) query.country = country; 64 | 65 | router.push( 66 | { 67 | pathname: '/search', 68 | query, 69 | }, 70 | undefined, 71 | { shallow: true }, 72 | ); 73 | } 74 | 75 | if (onChange) onChange({ term: term, country }); 76 | }, [debouncedTerm, country]); 77 | 78 | useEffect(() => { 79 | if (termParam?.length) { 80 | setTerm(termParam as string); 81 | setSearchTerm(termParam as string); 82 | } 83 | if (countryParam?.length) { 84 | setCountry(countryParam as string); 85 | setSearchCountry(countryParam as string); 86 | } 87 | 88 | setMounted(true); 89 | }, [termParam, countryParam]); 90 | 91 | return ( 92 | 99 | {showCountry && ( 100 | 101 | 113 | 114 | )} 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 130 | 131 | 132 | 133 | ); 134 | }; 135 | 136 | export default observer(Search); 137 | -------------------------------------------------------------------------------- /src/constants/countries.ts: -------------------------------------------------------------------------------- 1 | const countries = [ 2 | { name: 'Afghanistan', code: 'AF' }, 3 | { name: 'Åland Islands', code: 'AX' }, 4 | { name: 'Albania', code: 'AL' }, 5 | { name: 'Algeria', code: 'DZ' }, 6 | { name: 'American Samoa', code: 'AS' }, 7 | { name: 'AndorrA', code: 'AD' }, 8 | { name: 'Angola', code: 'AO' }, 9 | { name: 'Anguilla', code: 'AI' }, 10 | { name: 'Antarctica', code: 'AQ' }, 11 | { name: 'Antigua and Barbuda', code: 'AG' }, 12 | { name: 'Argentina', code: 'AR' }, 13 | { name: 'Armenia', code: 'AM' }, 14 | { name: 'Aruba', code: 'AW' }, 15 | { name: 'Australia', code: 'AU' }, 16 | { name: 'Austria', code: 'AT' }, 17 | { name: 'Azerbaijan', code: 'AZ' }, 18 | { name: 'Bahamas', code: 'BS' }, 19 | { name: 'Bahrain', code: 'BH' }, 20 | { name: 'Bangladesh', code: 'BD' }, 21 | { name: 'Barbados', code: 'BB' }, 22 | { name: 'Belarus', code: 'BY' }, 23 | { name: 'Belgium', code: 'BE' }, 24 | { name: 'Belize', code: 'BZ' }, 25 | { name: 'Benin', code: 'BJ' }, 26 | { name: 'Bermuda', code: 'BM' }, 27 | { name: 'Bhutan', code: 'BT' }, 28 | { name: 'Bolivia', code: 'BO' }, 29 | { name: 'Bosnia and Herzegovina', code: 'BA' }, 30 | { name: 'Botswana', code: 'BW' }, 31 | { name: 'Bouvet Island', code: 'BV' }, 32 | { name: 'Brazil', code: 'BR' }, 33 | { name: 'British Indian Ocean Territory', code: 'IO' }, 34 | { name: 'Brunei Darussalam', code: 'BN' }, 35 | { name: 'Bulgaria', code: 'BG' }, 36 | { name: 'Burkina Faso', code: 'BF' }, 37 | { name: 'Burundi', code: 'BI' }, 38 | { name: 'Cambodia', code: 'KH' }, 39 | { name: 'Cameroon', code: 'CM' }, 40 | { name: 'Canada', code: 'CA' }, 41 | { name: 'Cape Verde', code: 'CV' }, 42 | { name: 'Cayman Islands', code: 'KY' }, 43 | { name: 'Central African Republic', code: 'CF' }, 44 | { name: 'Chad', code: 'TD' }, 45 | { name: 'Chile', code: 'CL' }, 46 | { name: 'China', code: 'CN' }, 47 | { name: 'Christmas Island', code: 'CX' }, 48 | { name: 'Cocos (Keeling) Islands', code: 'CC' }, 49 | { name: 'Colombia', code: 'CO' }, 50 | { name: 'Comoros', code: 'KM' }, 51 | { name: 'Congo', code: 'CG' }, 52 | { name: 'Congo, The Democratic Republic of the', code: 'CD' }, 53 | { name: 'Cook Islands', code: 'CK' }, 54 | { name: 'Costa Rica', code: 'CR' }, 55 | { name: "Cote D'Ivoire", code: 'CI' }, 56 | { name: 'Croatia', code: 'HR' }, 57 | { name: 'Cuba', code: 'CU' }, 58 | { name: 'Cyprus', code: 'CY' }, 59 | { name: 'Czech Republic', code: 'CZ' }, 60 | { name: 'Denmark', code: 'DK' }, 61 | { name: 'Djibouti', code: 'DJ' }, 62 | { name: 'Dominica', code: 'DM' }, 63 | { name: 'Dominican Republic', code: 'DO' }, 64 | { name: 'Ecuador', code: 'EC' }, 65 | { name: 'Egypt', code: 'EG' }, 66 | { name: 'El Salvador', code: 'SV' }, 67 | { name: 'Equatorial Guinea', code: 'GQ' }, 68 | { name: 'Eritrea', code: 'ER' }, 69 | { name: 'Estonia', code: 'EE' }, 70 | { name: 'Ethiopia', code: 'ET' }, 71 | { name: 'Falkland Islands (Malvinas)', code: 'FK' }, 72 | { name: 'Faroe Islands', code: 'FO' }, 73 | { name: 'Fiji', code: 'FJ' }, 74 | { name: 'Finland', code: 'FI' }, 75 | { name: 'France', code: 'FR' }, 76 | { name: 'French Guiana', code: 'GF' }, 77 | { name: 'French Polynesia', code: 'PF' }, 78 | { name: 'French Southern Territories', code: 'TF' }, 79 | { name: 'Gabon', code: 'GA' }, 80 | { name: 'Gambia', code: 'GM' }, 81 | { name: 'Georgia', code: 'GE' }, 82 | { name: 'Germany', code: 'DE' }, 83 | { name: 'Ghana', code: 'GH' }, 84 | { name: 'Gibraltar', code: 'GI' }, 85 | { name: 'Greece', code: 'GR' }, 86 | { name: 'Greenland', code: 'GL' }, 87 | { name: 'Grenada', code: 'GD' }, 88 | { name: 'Guadeloupe', code: 'GP' }, 89 | { name: 'Guam', code: 'GU' }, 90 | { name: 'Guatemala', code: 'GT' }, 91 | { name: 'Guernsey', code: 'GG' }, 92 | { name: 'Guinea', code: 'GN' }, 93 | { name: 'Guinea-Bissau', code: 'GW' }, 94 | { name: 'Guyana', code: 'GY' }, 95 | { name: 'Haiti', code: 'HT' }, 96 | { name: 'Heard Island and Mcdonald Islands', code: 'HM' }, 97 | { name: 'Holy See (Vatican City State)', code: 'VA' }, 98 | { name: 'Honduras', code: 'HN' }, 99 | { name: 'Hong Kong', code: 'HK' }, 100 | { name: 'Hungary', code: 'HU' }, 101 | { name: 'Iceland', code: 'IS' }, 102 | { name: 'India', code: 'IN' }, 103 | { name: 'Indonesia', code: 'ID' }, 104 | { name: 'Iran, Islamic Republic Of', code: 'IR' }, 105 | { name: 'Iraq', code: 'IQ' }, 106 | { name: 'Ireland', code: 'IE' }, 107 | { name: 'Isle of Man', code: 'IM' }, 108 | { name: 'Israel', code: 'IL' }, 109 | { name: 'Italy', code: 'IT' }, 110 | { name: 'Jamaica', code: 'JM' }, 111 | { name: 'Japan', code: 'JP' }, 112 | { name: 'Jersey', code: 'JE' }, 113 | { name: 'Jordan', code: 'JO' }, 114 | { name: 'Kazakhstan', code: 'KZ' }, 115 | { name: 'Kenya', code: 'KE' }, 116 | { name: 'Kiribati', code: 'KI' }, 117 | { name: "Korea, Democratic People'S Republic of", code: 'KP' }, 118 | { name: 'Korea, Republic of', code: 'KR' }, 119 | { name: 'Kuwait', code: 'KW' }, 120 | { name: 'Kyrgyzstan', code: 'KG' }, 121 | { name: "Lao People'S Democratic Republic", code: 'LA' }, 122 | { name: 'Latvia', code: 'LV' }, 123 | { name: 'Lebanon', code: 'LB' }, 124 | { name: 'Lesotho', code: 'LS' }, 125 | { name: 'Liberia', code: 'LR' }, 126 | { name: 'Libyan Arab Jamahiriya', code: 'LY' }, 127 | { name: 'Liechtenstein', code: 'LI' }, 128 | { name: 'Lithuania', code: 'LT' }, 129 | { name: 'Luxembourg', code: 'LU' }, 130 | { name: 'Macao', code: 'MO' }, 131 | { name: 'Macedonia, The Former Yugoslav Republic of', code: 'MK' }, 132 | { name: 'Madagascar', code: 'MG' }, 133 | { name: 'Malawi', code: 'MW' }, 134 | { name: 'Malaysia', code: 'MY' }, 135 | { name: 'Maldives', code: 'MV' }, 136 | { name: 'Mali', code: 'ML' }, 137 | { name: 'Malta', code: 'MT' }, 138 | { name: 'Marshall Islands', code: 'MH' }, 139 | { name: 'Martinique', code: 'MQ' }, 140 | { name: 'Mauritania', code: 'MR' }, 141 | { name: 'Mauritius', code: 'MU' }, 142 | { name: 'Mayotte', code: 'YT' }, 143 | { name: 'Mexico', code: 'MX' }, 144 | { name: 'Micronesia, Federated States of', code: 'FM' }, 145 | { name: 'Moldova, Republic of', code: 'MD' }, 146 | { name: 'Monaco', code: 'MC' }, 147 | { name: 'Mongolia', code: 'MN' }, 148 | { name: 'Montserrat', code: 'MS' }, 149 | { name: 'Morocco', code: 'MA' }, 150 | { name: 'Mozambique', code: 'MZ' }, 151 | { name: 'Myanmar', code: 'MM' }, 152 | { name: 'Namibia', code: 'NA' }, 153 | { name: 'Nauru', code: 'NR' }, 154 | { name: 'Nepal', code: 'NP' }, 155 | { name: 'Netherlands', code: 'NL' }, 156 | { name: 'Netherlands Antilles', code: 'AN' }, 157 | { name: 'New Caledonia', code: 'NC' }, 158 | { name: 'New Zealand', code: 'NZ' }, 159 | { name: 'Nicaragua', code: 'NI' }, 160 | { name: 'Niger', code: 'NE' }, 161 | { name: 'Nigeria', code: 'NG' }, 162 | { name: 'Niue', code: 'NU' }, 163 | { name: 'Norfolk Island', code: 'NF' }, 164 | { name: 'Northern Mariana Islands', code: 'MP' }, 165 | { name: 'Norway', code: 'NO' }, 166 | { name: 'Oman', code: 'OM' }, 167 | { name: 'Pakistan', code: 'PK' }, 168 | { name: 'Palau', code: 'PW' }, 169 | { name: 'Palestinian Territory, Occupied', code: 'PS' }, 170 | { name: 'Panama', code: 'PA' }, 171 | { name: 'Papua New Guinea', code: 'PG' }, 172 | { name: 'Paraguay', code: 'PY' }, 173 | { name: 'Peru', code: 'PE' }, 174 | { name: 'Philippines', code: 'PH' }, 175 | { name: 'Pitcairn', code: 'PN' }, 176 | { name: 'Poland', code: 'PL' }, 177 | { name: 'Portugal', code: 'PT' }, 178 | { name: 'Puerto Rico', code: 'PR' }, 179 | { name: 'Qatar', code: 'QA' }, 180 | { name: 'Reunion', code: 'RE' }, 181 | { name: 'Romania', code: 'RO' }, 182 | { name: 'Russian Federation', code: 'RU' }, 183 | { name: 'RWANDA', code: 'RW' }, 184 | { name: 'Saint Helena', code: 'SH' }, 185 | { name: 'Saint Kitts and Nevis', code: 'KN' }, 186 | { name: 'Saint Lucia', code: 'LC' }, 187 | { name: 'Saint Pierre and Miquelon', code: 'PM' }, 188 | { name: 'Saint Vincent and the Grenadines', code: 'VC' }, 189 | { name: 'Samoa', code: 'WS' }, 190 | { name: 'San Marino', code: 'SM' }, 191 | { name: 'Sao Tome and Principe', code: 'ST' }, 192 | { name: 'Saudi Arabia', code: 'SA' }, 193 | { name: 'Senegal', code: 'SN' }, 194 | { name: 'Serbia and Montenegro', code: 'CS' }, 195 | { name: 'Seychelles', code: 'SC' }, 196 | { name: 'Sierra Leone', code: 'SL' }, 197 | { name: 'Singapore', code: 'SG' }, 198 | { name: 'Slovakia', code: 'SK' }, 199 | { name: 'Slovenia', code: 'SI' }, 200 | { name: 'Solomon Islands', code: 'SB' }, 201 | { name: 'Somalia', code: 'SO' }, 202 | { name: 'South Africa', code: 'ZA' }, 203 | { name: 'South Georgia and the South Sandwich Islands', code: 'GS' }, 204 | { name: 'Spain', code: 'ES' }, 205 | { name: 'Sri Lanka', code: 'LK' }, 206 | { name: 'Sudan', code: 'SD' }, 207 | { name: 'Suriname', code: 'SR' }, 208 | { name: 'Svalbard and Jan Mayen', code: 'SJ' }, 209 | { name: 'Swaziland', code: 'SZ' }, 210 | { name: 'Sweden', code: 'SE' }, 211 | { name: 'Switzerland', code: 'CH' }, 212 | { name: 'Syrian Arab Republic', code: 'SY' }, 213 | { name: 'Taiwan, Province of China', code: 'TW' }, 214 | { name: 'Tajikistan', code: 'TJ' }, 215 | { name: 'Tanzania, United Republic of', code: 'TZ' }, 216 | { name: 'Thailand', code: 'TH' }, 217 | { name: 'Timor-Leste', code: 'TL' }, 218 | { name: 'Togo', code: 'TG' }, 219 | { name: 'Tokelau', code: 'TK' }, 220 | { name: 'Tonga', code: 'TO' }, 221 | { name: 'Trinidad and Tobago', code: 'TT' }, 222 | { name: 'Tunisia', code: 'TN' }, 223 | { name: 'Turkey', code: 'TR' }, 224 | { name: 'Turkmenistan', code: 'TM' }, 225 | { name: 'Turks and Caicos Islands', code: 'TC' }, 226 | { name: 'Tuvalu', code: 'TV' }, 227 | { name: 'Uganda', code: 'UG' }, 228 | { name: 'Ukraine', code: 'UA' }, 229 | { name: 'United Arab Emirates', code: 'AE' }, 230 | { name: 'United Kingdom', code: 'GB' }, 231 | { name: 'United States', code: 'US' }, 232 | { name: 'United States Minor Outlying Islands', code: 'UM' }, 233 | { name: 'Uruguay', code: 'UY' }, 234 | { name: 'Uzbekistan', code: 'UZ' }, 235 | { name: 'Vanuatu', code: 'VU' }, 236 | { name: 'Venezuela', code: 'VE' }, 237 | { name: 'Viet Nam', code: 'VN' }, 238 | { name: 'Virgin Islands, British', code: 'VG' }, 239 | { name: 'Virgin Islands, U.S.', code: 'VI' }, 240 | { name: 'Wallis and Futuna', code: 'WF' }, 241 | { name: 'Western Sahara', code: 'EH' }, 242 | { name: 'Yemen', code: 'YE' }, 243 | { name: 'Zambia', code: 'ZM' }, 244 | { name: 'Zimbabwe', code: 'ZW' }, 245 | ]; 246 | 247 | export default countries; 248 | -------------------------------------------------------------------------------- /src/constants/message.ts: -------------------------------------------------------------------------------- 1 | export const ERROR_STATE = 'Something unexpected happened. Please try again.'; 2 | export const EMPTY_STATE = 'No search results found. Please try a different term.'; 3 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | import useDebounce from './useDebounce'; 2 | import useStore from './useStore'; 3 | 4 | export { useStore, useDebounce }; 5 | -------------------------------------------------------------------------------- /src/hooks/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | const useDebounce = (value: any, timeout: number) => { 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 | 15 | export default useDebounce; 16 | -------------------------------------------------------------------------------- /src/hooks/useStore.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { RootStoreContext } from '../stores/rootStore'; 4 | 5 | const useStore = () => useContext(RootStoreContext); 6 | 7 | export default useStore; 8 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { ChakraProvider, ColorModeProvider } from '@chakra-ui/react'; 2 | import type { AppProps } from 'next/app'; 3 | import 'react-h5-audio-player/lib/styles.css'; 4 | import { ToastContainer } from 'react-toastify'; 5 | import 'react-toastify/dist/ReactToastify.css'; 6 | 7 | import Layout from '../components/Layout'; 8 | import RootStore, { RootStoreProvider } from '../stores/rootStore'; 9 | import theme from '../theme/theme'; 10 | 11 | const store = new RootStore(); 12 | 13 | const App = ({ Component, pageProps }: AppProps) => ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | 26 | export default App; 27 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { ColorModeScript } from '@chakra-ui/react'; 2 | import Document, { Head, Html, Main, NextScript } from 'next/document'; 3 | 4 | import theme from '../theme/theme'; 5 | 6 | export default class MyDocument extends Document { 7 | render() { 8 | return ( 9 | 10 | 11 | 12 | 13 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/pages/api/collections/[id].ts: -------------------------------------------------------------------------------- 1 | import type { AxiosError } from 'axios'; 2 | import type { NextApiRequest, NextApiResponse } from 'next'; 3 | import RssParser from 'rss-parser'; 4 | 5 | import api from '../../../services/api'; 6 | 7 | type Data = Collection | AxiosError; 8 | 9 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 10 | try { 11 | const { id } = req.query; 12 | 13 | const { status, data } = await api.get(`lookup`, { 14 | params: { 15 | id, 16 | }, 17 | }); 18 | 19 | if (status !== 200 || !data?.results?.length || !data.results[0]?.feedUrl?.length) { 20 | res.status(status).end(); 21 | } 22 | 23 | const { 24 | collectionId, 25 | artistName, 26 | collectionName, 27 | feedUrl, 28 | artworkUrl100, 29 | artworkUrl600, 30 | genres, 31 | primaryGenreName, 32 | } = data.results[0]; 33 | 34 | const feed = await new RssParser().parseURL(feedUrl); 35 | 36 | const { description, managingEditor, language, copyright, lastBuildDate, items } = feed; 37 | 38 | res.status(status).json({ 39 | collectionId, 40 | artistName, 41 | collectionName, 42 | feedUrl, 43 | artworkUrl100, 44 | artworkUrl600, 45 | genres, 46 | description, 47 | managingEditor, 48 | language, 49 | copyright, 50 | lastBuildDate, 51 | primaryGenreName, 52 | items: items.map(({ title, link, isoDate, enclosure, content, itunes }) => ({ 53 | title, 54 | link, 55 | isoDate, 56 | enclosure, 57 | content, 58 | itunes, 59 | })) as Podcast[] | any, 60 | } as Collection); 61 | } catch (error) { 62 | console.log(error); 63 | res.status(500).json(error as AxiosError); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/pages/api/collections/index.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosError } from 'axios'; 2 | import type { NextApiRequest, NextApiResponse } from 'next'; 3 | 4 | import api from '../../../services/api'; 5 | 6 | type Data = Collection[] | AxiosError; 7 | 8 | type Params = { 9 | country?: string; 10 | term?: string; 11 | entity: string; 12 | limit: number; 13 | }; 14 | 15 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 16 | try { 17 | const { 18 | query: { country, term }, 19 | } = req; 20 | 21 | const params = { 22 | entity: 'podcast', 23 | media: 'podcast', 24 | limit: 50, 25 | } as Params; 26 | 27 | if (typeof term === 'string' && term?.length) params.term = term; 28 | if (typeof country === 'string' && country?.length) params.country = country; 29 | 30 | const { status, data } = await api.get(`search`, { 31 | params, 32 | }); 33 | 34 | res.status(status).json(data?.results); 35 | } catch (error) { 36 | console.log(error); 37 | res.status(500).json(error as AxiosError); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/pages/api/collections/rank.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosError } from 'axios'; 2 | import type { NextApiRequest, NextApiResponse } from 'next'; 3 | 4 | import api from '../../../services/api'; 5 | 6 | type Data = Collection[] | AxiosError; 7 | 8 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 9 | try { 10 | const { 11 | query: { country }, 12 | } = req; 13 | 14 | const { status, data } = await api.get( 15 | `https://rss.applemarketingtools.com/api/v2/${ 16 | country?.length ? country : 'br' 17 | }/podcasts/top/10/podcasts.json`, 18 | ); 19 | 20 | res.status(status).json( 21 | data?.feed?.results?.map( 22 | ({ 23 | artistName, 24 | id, 25 | name, 26 | artworkUrl100, 27 | genres, 28 | }: { 29 | artistName: string; 30 | id: string; 31 | name: string; 32 | artworkUrl100: string; 33 | genres: { name: string }[]; 34 | }) => ({ 35 | artistName, 36 | collectionId: Number(id), 37 | collectionName: name, 38 | artworkUrl100, 39 | primaryGenreName: genres[0].name, 40 | genres, 41 | }), 42 | ), 43 | ); 44 | } catch (error) { 45 | console.log(error); 46 | res.status(500).json(error as AxiosError); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/pages/collections/[id].tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Badge, 3 | Breadcrumb, 4 | BreadcrumbItem, 5 | BreadcrumbLink, 6 | Container, 7 | Flex, 8 | Icon, 9 | Image, 10 | Text, 11 | useColorMode, 12 | } from '@chakra-ui/react'; 13 | import { observer } from 'mobx-react'; 14 | import type { NextPage } from 'next'; 15 | import Head from 'next/head'; 16 | import { useRouter } from 'next/router'; 17 | import { useEffect, useState } from 'react'; 18 | import { RiArrowLeftSLine, RiHomeLine } from 'react-icons/ri'; 19 | import LazyLoad, { forceCheck } from 'react-lazyload'; 20 | 21 | import EmptyState from '../../components/EmptyState'; 22 | import Loader from '../../components/Loader'; 23 | import PodcastListItem from '../../components/PodcastListItem'; 24 | import Search from '../../components/Search'; 25 | import { useStore } from '../../hooks'; 26 | 27 | const CollectionDetail: NextPage = () => { 28 | const router = useRouter(); 29 | const [mounted, setMounted] = useState(false); 30 | const [searchTerm, setSearchTerm] = useState(''); 31 | const { collectionStore } = useStore(); 32 | const { colorMode } = useColorMode(); 33 | 34 | const { detail, detailStatus, detailSearchResult, getDetail, search } = collectionStore; 35 | 36 | const searchEpisodes = (params: { term: string }) => { 37 | const { term } = params; 38 | search(params); 39 | setSearchTerm(term); 40 | }; 41 | 42 | const renderDetail = () => { 43 | switch (detailStatus) { 44 | case 'fetching': { 45 | return ; 46 | } 47 | 48 | case 'empty': { 49 | return ; 50 | } 51 | 52 | case 'error': { 53 | return ; 54 | } 55 | 56 | case 'success': { 57 | if (detail) { 58 | const { 59 | artistName, 60 | collectionId, 61 | collectionName, 62 | artworkUrl100, 63 | artworkUrl600, 64 | description, 65 | managingEditor, 66 | language, 67 | copyright, 68 | lastBuildDate, 69 | primaryGenreName, 70 | genres, 71 | feedUrl, 72 | trackCount, 73 | country, 74 | items, 75 | } = detail; 76 | 77 | return ( 78 | <> 79 | 88 | 97 | {collectionName} 104 | 105 | 106 | 111 | 112 | {collectionName} 113 | 114 | 115 | {artistName} 116 | 117 | {!!description?.length && ( 118 | 119 | )} 120 | 121 | {primaryGenreName} 122 | 123 | 124 | {!!copyright?.length && ( 125 | 126 | {`${!copyright.includes('©') ? `© ${copyright}` : `${copyright}`}`} 127 | 128 | )} 129 | 130 | 131 | 132 | {detailSearchResult?.length ? ( 133 | detailSearchResult.map((podcast) => ( 134 | 141 | 142 | 143 | )) 144 | ) : ( 145 | 149 | )} 150 | 151 | 152 | ); 153 | } 154 | } 155 | 156 | default: { 157 | return null; 158 | } 159 | } 160 | }; 161 | 162 | const init = async (id: string) => { 163 | if (String(detail?.collectionId) !== id) await getDetail({ id }); 164 | }; 165 | 166 | useEffect(() => { 167 | forceCheck(); 168 | }, [detailSearchResult]); 169 | 170 | useEffect(() => { 171 | if (mounted) return; 172 | 173 | const { id } = router.query; 174 | 175 | if (id?.length) { 176 | init(id as string); 177 | setMounted(true); 178 | } 179 | }, [router.query]); 180 | 181 | return ( 182 | <> 183 | 184 | PodJS 185 | 186 | 187 | 188 | 189 | 190 | 202 | 211 | 212 | {!!detail?.collectionName?.length && ( 213 | 214 | } 216 | sx={{ 217 | 'span, ol': { 218 | display: 'flex', 219 | alignItems: 'center', 220 | }, 221 | }} 222 | > 223 | 224 | router.push('/')}> 225 | 226 | 227 | Home 228 | 229 | 230 | 231 | 232 | 233 | 238 | 239 | {detail.collectionName} 240 | 241 | 242 | 243 | 244 | 245 | )} 246 | 247 | 252 | 253 | 254 | 255 | 256 | 264 | 265 | {renderDetail()} 266 | 267 | 268 | 269 | 270 | 271 | ); 272 | }; 273 | 274 | export default observer(CollectionDetail); 275 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Container, 4 | Flex, 5 | Icon, 6 | IconButton, 7 | Menu, 8 | MenuButton, 9 | MenuItem, 10 | MenuList, 11 | Modal, 12 | ModalBody, 13 | ModalCloseButton, 14 | ModalContent, 15 | ModalFooter, 16 | ModalHeader, 17 | ModalOverlay, 18 | Select, 19 | SimpleGrid, 20 | Text, 21 | useColorMode, 22 | useDisclosure, 23 | } from '@chakra-ui/react'; 24 | import { observer } from 'mobx-react'; 25 | import type { NextPage } from 'next'; 26 | import Head from 'next/head'; 27 | import { ChangeEvent, useEffect, useRef, useState } from 'react'; 28 | import { RiSettingsLine, RiStarLine, RiTrophyLine } from 'react-icons/ri'; 29 | 30 | import CollectionListItem from '../components/CollectionListItem'; 31 | import EmptyState from '../components/EmptyState'; 32 | import Loader from '../components/Loader'; 33 | import RankCollectionListItem from '../components/RankCollectionListItem'; 34 | import Search from '../components/Search'; 35 | import countries from '../constants/countries'; 36 | import { useStore } from '../hooks'; 37 | 38 | const Home: NextPage = () => { 39 | const { collectionStore } = useStore(); 40 | const { colorMode } = useColorMode(); 41 | const { isOpen, onOpen, onClose } = useDisclosure(); 42 | const btnRef = useRef(null); 43 | 44 | const { 45 | rank, 46 | favorites, 47 | rankStatus, 48 | rankCountry, 49 | getRank, 50 | getList, 51 | loadStoredData, 52 | setRankCountry, 53 | } = collectionStore; 54 | 55 | const handleCountryChange = (event: ChangeEvent) => { 56 | setRankCountry(event.target.value); 57 | getRank(); 58 | }; 59 | 60 | const renderFavorites = () => { 61 | if (!favorites?.length) return null; 62 | 63 | return ( 64 | 65 | 76 | 77 | 78 | 79 | Favorites 80 | 81 | 82 | 83 | 84 | {favorites.map((collection) => ( 85 | 86 | ))} 87 | 88 | 89 | ); 90 | }; 91 | 92 | const renderRank = () => { 93 | switch (rankStatus) { 94 | case 'fetching': { 95 | return ; 96 | } 97 | 98 | case 'empty': { 99 | return ; 100 | } 101 | 102 | case 'error': { 103 | return ; 104 | } 105 | 106 | case 'success': { 107 | if (rank?.length) { 108 | return ( 109 | 110 | {rank.map((collection, index) => ( 111 | 116 | ))} 117 | 118 | ); 119 | } 120 | } 121 | 122 | default: { 123 | return null; 124 | } 125 | } 126 | }; 127 | 128 | useEffect(() => { 129 | loadStoredData(); 130 | if (!rank?.length) getRank(); 131 | }, []); 132 | 133 | return ( 134 |
135 | 136 | PodJS 137 | 138 | 139 | 140 | 141 | 142 | 151 | 160 | 161 | 162 | 163 | 164 | 165 | 174 | 179 | {renderFavorites()} 180 | 186 | 199 | 200 | 201 | 202 | Ranking 203 | 204 | 205 | } 208 | size='sm' 209 | variant='ghost' 210 | onClick={onOpen} 211 | /> 212 | 219 | 220 | 221 | Ranking settings 222 | 223 | 224 | 225 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | {renderRank()} 246 | 247 | 248 | 249 | 250 |
251 | ); 252 | }; 253 | 254 | export default observer(Home); 255 | -------------------------------------------------------------------------------- /src/pages/search/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Breadcrumb, 3 | BreadcrumbItem, 4 | BreadcrumbLink, 5 | Container, 6 | Flex, 7 | Icon, 8 | SimpleGrid, 9 | Text, 10 | useColorMode, 11 | } from '@chakra-ui/react'; 12 | import { observer } from 'mobx-react'; 13 | import type { NextPage } from 'next'; 14 | import Head from 'next/head'; 15 | import { useRouter } from 'next/router'; 16 | import { RiArrowLeftSLine, RiHomeLine, RiSearchLine } from 'react-icons/ri'; 17 | 18 | import CollectionListItem from '../../components/CollectionListItem'; 19 | import EmptyState from '../../components/EmptyState'; 20 | import Loader from '../../components/Loader'; 21 | import Search from '../../components/Search'; 22 | import countries from '../../constants/countries'; 23 | import { useStore } from '../../hooks'; 24 | 25 | const SearchScreen: NextPage = () => { 26 | const router = useRouter(); 27 | const { collectionStore } = useStore(); 28 | const { colorMode } = useColorMode(); 29 | 30 | const { list, listStatus, searchTerm, searchCountry, getList } = collectionStore; 31 | 32 | const renderList = () => { 33 | let listContent = null; 34 | const country = countries.find(({ code }) => code.toLocaleLowerCase() === searchCountry); 35 | 36 | switch (listStatus) { 37 | case 'fetching': { 38 | listContent = ; 39 | break; 40 | } 41 | 42 | case 'empty': { 43 | listContent = ( 44 | 54 | ); 55 | break; 56 | } 57 | 58 | case 'error': { 59 | listContent = ; 60 | break; 61 | } 62 | 63 | case 'success': { 64 | if (list?.length) { 65 | listContent = list.map((collection) => ( 66 | 67 | )); 68 | } else { 69 | listContent = ( 70 | 80 | ); 81 | } 82 | break; 83 | } 84 | 85 | default: { 86 | listContent = ( 87 | 97 | ); 98 | break; 99 | } 100 | } 101 | 102 | return ( 103 | 104 | {!!searchTerm.trim().length && ( 105 | 117 | 118 | 119 | {`${!!list?.length ? `${list.length} ` : ''}Search results for "${searchTerm}"${ 120 | country ? ` in ${country.name}` : '' 121 | }:`} 122 | 123 | 124 | )} 125 | 126 | {listContent} 127 | 128 | 129 | ); 130 | }; 131 | 132 | return ( 133 |
134 | 135 | PodJS 136 | 137 | 138 | 139 | 140 | 141 | 151 | 160 | 161 | 162 | } 164 | sx={{ 165 | 'span, ol': { 166 | display: 'flex', 167 | alignItems: 'center', 168 | }, 169 | }} 170 | > 171 | 172 | router.push('/')}> 173 | 174 | 175 | Home 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | Search 184 | 185 | 186 | 187 | 188 | 189 | 190 | 196 | 197 | 198 | 199 | 200 | 209 | {renderList()} 210 | 211 | 212 |
213 | ); 214 | }; 215 | 216 | export default observer(SearchScreen); 217 | -------------------------------------------------------------------------------- /src/services/api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const baseURL = 'https://itunes.apple.com/'; 4 | 5 | const api = axios.create({ 6 | baseURL, 7 | timeout: 20000, 8 | }); 9 | 10 | export default api; 11 | -------------------------------------------------------------------------------- /src/stores/collectionStore.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { makeAutoObservable } from 'mobx'; 3 | import { toast } from 'react-toastify'; 4 | 5 | import { ERROR_STATE } from '../constants/message'; 6 | import { normalizeString } from '../utils'; 7 | import type RootStore from './rootStore'; 8 | 9 | export default class CollectionStore { 10 | rootStore: RootStore; 11 | list: Collection[] | null = null; 12 | rank: Collection[] | null = null; 13 | favorites: Collection[] = []; 14 | detail: Collection | null = null; 15 | detailSearchResult: Podcast[] | null = null; 16 | listStatus: FetchStatus = 'idle'; 17 | rankStatus: FetchStatus = 'idle'; 18 | detailStatus: FetchStatus = 'idle'; 19 | searchTerm: string = ''; 20 | searchCountry: string = ''; 21 | rankCountry: string = ''; 22 | 23 | constructor(rootStore: RootStore) { 24 | makeAutoObservable(this, { rootStore: false }); 25 | this.rootStore = rootStore; 26 | } 27 | 28 | setDetail = (detail?: Collection): void => { 29 | this.detail = detail || null; 30 | }; 31 | 32 | setList = (list?: Collection[]): void => { 33 | this.list = list || null; 34 | }; 35 | 36 | setListStatus = (status?: FetchStatus): void => { 37 | this.listStatus = status || 'idle'; 38 | }; 39 | 40 | setRankStatus = (status?: FetchStatus): void => { 41 | this.rankStatus = status || 'idle'; 42 | }; 43 | 44 | setDetailStatus = (status?: FetchStatus): void => { 45 | this.detailStatus = status || 'idle'; 46 | }; 47 | 48 | setSearchTerm = (term?: string): void => { 49 | this.searchTerm = term || ''; 50 | }; 51 | 52 | setSearchCountry = (country?: string): void => { 53 | this.searchCountry = country || ''; 54 | }; 55 | 56 | setRankCountry = (country?: string): void => { 57 | this.rankCountry = country || ''; 58 | this.storeRankCountry(); 59 | }; 60 | 61 | setRank = (rank?: Collection[]): void => { 62 | this.rank = rank || null; 63 | }; 64 | 65 | setFavorites = (favorites?: Collection[]): void => { 66 | this.favorites = favorites || []; 67 | this.storeFavorites(); 68 | }; 69 | 70 | setDetailSearchResult = (detailSearchResult?: Podcast[]): void => { 71 | this.detailSearchResult = detailSearchResult || []; 72 | }; 73 | 74 | storeFavorites = (): void => { 75 | localStorage.setItem('favorites', JSON.stringify(this.favorites)); 76 | }; 77 | 78 | storeRankCountry = (): void => { 79 | localStorage.setItem('rankCountry', this.rankCountry); 80 | }; 81 | 82 | addCollectionToFavorites = (collection: Collection): void => { 83 | if (!this.favorites?.find(({ collectionId }) => collectionId === collection.collectionId)) { 84 | this.setFavorites([...this.favorites, collection]); 85 | } 86 | }; 87 | 88 | removeCollectionFromFavorites = (collection: Collection): void => { 89 | const newFavorites = this.favorites.filter( 90 | ({ collectionId }) => collectionId !== collection.collectionId, 91 | ); 92 | 93 | this.setFavorites(newFavorites); 94 | }; 95 | 96 | getList = async (payload: { 97 | term: string; 98 | country: string; 99 | }): Promise => { 100 | try { 101 | const { term, country } = payload; 102 | 103 | if (this.list?.length && term === this.searchTerm && country === this.searchCountry) { 104 | return; 105 | } 106 | 107 | this.setListStatus('fetching'); 108 | this.setList(); 109 | 110 | const params = {} as { term: string; country: string }; 111 | 112 | if (term?.length) { 113 | this.setSearchTerm(term); 114 | params.term = term; 115 | } else { 116 | this.setSearchTerm(); 117 | } 118 | 119 | if (country?.length) { 120 | this.setSearchCountry(country); 121 | params.country = country; 122 | } else { 123 | this.setSearchCountry(); 124 | } 125 | 126 | const response = await axios.get('/api/collections', { 127 | params, 128 | }); 129 | 130 | const { status, data } = response as { status: number; data: Collection[] }; 131 | 132 | if (status === 200 && !data?.length) { 133 | this.setListStatus('empty'); 134 | 135 | return { 136 | status: status || 400, 137 | }; 138 | } 139 | 140 | if (status !== 200 || !data) { 141 | this.setListStatus('error'); 142 | 143 | return { 144 | status: status || 400, 145 | }; 146 | } 147 | 148 | this.setList(data); 149 | this.setListStatus('success'); 150 | 151 | return { status }; 152 | } catch (error) { 153 | console.warn(error); 154 | 155 | toast.error(ERROR_STATE); 156 | 157 | this.setListStatus('error'); 158 | 159 | return { 160 | status: 400, 161 | }; 162 | } 163 | }; 164 | 165 | getRank = async (): Promise => { 166 | try { 167 | this.setRankStatus('fetching'); 168 | this.setRank(); 169 | 170 | const params = { 171 | country: this.rankCountry, 172 | } as { country: string }; 173 | 174 | const response = await axios.get('/api/collections/rank', { 175 | params, 176 | }); 177 | 178 | const { status, data } = response as { status: number; data: Collection[] }; 179 | 180 | if (status === 200 && !data?.length) { 181 | this.setRankStatus('empty'); 182 | 183 | return { 184 | status: status || 400, 185 | }; 186 | } 187 | 188 | if (status !== 200 || !data) { 189 | this.setRankStatus('error'); 190 | 191 | return { 192 | status: status || 400, 193 | }; 194 | } 195 | 196 | this.setRank(data); 197 | this.setRankStatus('success'); 198 | 199 | return { status }; 200 | } catch (error) { 201 | console.warn(error); 202 | 203 | toast.error(ERROR_STATE); 204 | 205 | this.setRankStatus('error'); 206 | 207 | return { 208 | status: 400, 209 | }; 210 | } 211 | }; 212 | 213 | getDetail = async (payload: { id: string | string[] }): Promise => { 214 | try { 215 | this.setDetailStatus('fetching'); 216 | this.setDetail(); 217 | this.setDetailSearchResult(); 218 | 219 | const { id } = payload; 220 | 221 | const response = await axios.get(`/api/collections/${id}`); 222 | 223 | const { status, data } = response; 224 | 225 | if (status !== 200 || !data) { 226 | this.setDetailStatus('error'); 227 | 228 | return { 229 | status: status || 400, 230 | }; 231 | } 232 | 233 | this.setDetail(data); 234 | this.setDetailSearchResult(data.items); 235 | this.setDetailStatus('success'); 236 | 237 | return { status }; 238 | } catch (error) { 239 | console.warn(error); 240 | 241 | toast.error(ERROR_STATE); 242 | 243 | this.setDetailStatus('error'); 244 | 245 | return { 246 | status: 400, 247 | }; 248 | } 249 | }; 250 | 251 | search = (payload: { term?: string }): void => { 252 | const { term } = payload; 253 | this.setDetailSearchResult(); 254 | 255 | if (!this.detail?.items) return; 256 | 257 | if (term?.length) { 258 | this.setDetailSearchResult( 259 | this.detail.items.filter((item) => 260 | normalizeString(item.title.toLowerCase()).includes( 261 | normalizeString(term.toLowerCase()), 262 | ), 263 | ), 264 | ); 265 | 266 | return; 267 | } 268 | 269 | this.setDetailSearchResult(this.detail.items); 270 | }; 271 | 272 | loadStoredData = (): void => { 273 | const storedFavorites = localStorage.getItem('favorites') || '[]'; 274 | const parsedStoredFavorites: Collection[] = JSON.parse(storedFavorites); 275 | 276 | const storedRankCountry = localStorage.getItem('rankCountry') || 'br'; 277 | const parsedStoredRankCountry: string = storedRankCountry; 278 | 279 | if (parsedStoredFavorites?.length) this.setFavorites(parsedStoredFavorites); 280 | if (parsedStoredRankCountry?.length) this.setRankCountry(parsedStoredRankCountry); 281 | }; 282 | 283 | reset = (): void => { 284 | this.setList(); 285 | this.setRank(); 286 | this.setDetail(); 287 | this.setDetailSearchResult(); 288 | this.setListStatus(); 289 | this.setDetailStatus(); 290 | this.setSearchTerm(); 291 | this.setSearchCountry(); 292 | this.setRankStatus(); 293 | }; 294 | } 295 | -------------------------------------------------------------------------------- /src/stores/playerStore.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from 'mobx'; 2 | 3 | import type RootStore from './rootStore'; 4 | 5 | export default class PlayerStore { 6 | rootStore: RootStore; 7 | currentPodcast: Podcast | null = null; 8 | playList: Podcast[] = []; 9 | 10 | constructor(rootStore: RootStore) { 11 | makeAutoObservable(this, { rootStore: false }); 12 | this.rootStore = rootStore; 13 | } 14 | 15 | setCurrentPodcast = (podcast?: Podcast): void => { 16 | this.currentPodcast = podcast || null; 17 | this.storeCurrentPodcast(); 18 | }; 19 | 20 | setPlayList = (playlist?: Podcast[]): void => { 21 | this.playList = playlist || []; 22 | this.storePlaylist(); 23 | }; 24 | 25 | addPodcastToPlayList = (podcast: Podcast): void => { 26 | if (!this.playList.find(({ enclosure }) => enclosure.url === podcast?.enclosure?.url)) { 27 | this.setPlayList([...this.playList, podcast]); 28 | } 29 | }; 30 | 31 | removePodcastFromPlaylist = (podcast: Podcast): void => { 32 | const newPlaylist = this.playList.filter( 33 | ({ enclosure }) => enclosure.url !== podcast?.enclosure?.url, 34 | ); 35 | 36 | if (!newPlaylist.length || podcast.enclosure.url === this.currentPodcast?.enclosure.url) { 37 | this.setCurrentPodcast(); 38 | 39 | const audio = document.querySelector('audio'); 40 | 41 | if (audio) { 42 | audio.src = ''; 43 | audio.currentTime = 0; 44 | audio.pause(); 45 | } 46 | } 47 | 48 | this.setPlayList(newPlaylist); 49 | }; 50 | 51 | next = (): void => { 52 | let continueLoop = true; 53 | 54 | this.playList.forEach((podcast, index) => { 55 | const nextPodcast = this.playList[index + 1]; 56 | 57 | if ( 58 | continueLoop && 59 | nextPodcast && 60 | podcast.enclosure.url === this.currentPodcast?.enclosure.url 61 | ) { 62 | this.setCurrentPodcast(nextPodcast); 63 | 64 | continueLoop = false; 65 | } 66 | }); 67 | }; 68 | 69 | previous = (): void => { 70 | this.playList.forEach((podcast, index) => { 71 | const previousPodcast = this.playList[index - 1]; 72 | 73 | if (previousPodcast && podcast.enclosure.url === this.currentPodcast?.enclosure.url) { 74 | this.setCurrentPodcast(previousPodcast); 75 | } 76 | }); 77 | }; 78 | 79 | storePlaylist = (): void => { 80 | localStorage.setItem('playList', JSON.stringify(this.playList)); 81 | }; 82 | 83 | storeCurrentPodcast = (): void => { 84 | localStorage.setItem('currentPodcast', JSON.stringify(this.currentPodcast)); 85 | }; 86 | 87 | storeCurrentTime = (time: number): void => { 88 | localStorage.setItem('currentTime', JSON.stringify(time)); 89 | }; 90 | 91 | loadPlayerData = (): void => { 92 | const storedPlayList = localStorage.getItem('playList') || '[]'; 93 | const storedCurrentPodcast = localStorage.getItem('currentPodcast') || 'null'; 94 | const storedCurrentTime = localStorage.getItem('currentTime') || '0'; 95 | 96 | const parsedStoredPlayList: Podcast[] = JSON.parse(storedPlayList); 97 | const parsedStoredCurrentPodcast: Podcast = JSON.parse(storedCurrentPodcast); 98 | 99 | if (Array.isArray(parsedStoredPlayList) && parsedStoredPlayList.length) { 100 | this.setPlayList(parsedStoredPlayList); 101 | } 102 | 103 | if (parsedStoredCurrentPodcast?.enclosure?.url?.length) { 104 | this.setCurrentPodcast(parsedStoredCurrentPodcast); 105 | } 106 | 107 | if (storedCurrentTime) { 108 | const audio = document.querySelector('audio'); 109 | if (audio) audio.currentTime = Number(storedCurrentTime); 110 | } 111 | }; 112 | 113 | reset = (): void => { 114 | this.setCurrentPodcast(); 115 | this.setPlayList(); 116 | }; 117 | } 118 | -------------------------------------------------------------------------------- /src/stores/rootStore.ts: -------------------------------------------------------------------------------- 1 | import { configure } from 'mobx'; 2 | import { createContext } from 'react'; 3 | 4 | import CollectionStore from './collectionStore'; 5 | import PlayerStore from './playerStore'; 6 | import UiStore from './uiStore'; 7 | 8 | class RootStore { 9 | collectionStore = new CollectionStore(this); 10 | playerStore = new PlayerStore(this); 11 | uiStore = new UiStore(this); 12 | 13 | constructor() { 14 | configure({ 15 | enforceActions: 'never', 16 | }); 17 | } 18 | } 19 | 20 | export const RootStoreContext = createContext({} as RootStore); 21 | 22 | export const RootStoreProvider = RootStoreContext.Provider; 23 | 24 | export default RootStore; 25 | -------------------------------------------------------------------------------- /src/stores/uiStore.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from 'mobx'; 2 | 3 | import type RootStore from './rootStore'; 4 | 5 | export default class UiStore { 6 | rootStore: RootStore; 7 | collectionDetailModalIsOpen = false; 8 | playListIsOpen = false; 9 | drawerIsOpen = false; 10 | 11 | constructor(rootStore: RootStore) { 12 | makeAutoObservable(this, { rootStore: false }); 13 | this.rootStore = rootStore; 14 | } 15 | 16 | openPlayList = (): void => { 17 | this.playListIsOpen = true; 18 | }; 19 | 20 | closePlayList = (): void => { 21 | this.playListIsOpen = false; 22 | }; 23 | 24 | openDrawer = (): void => { 25 | this.drawerIsOpen = true; 26 | }; 27 | 28 | closeDrawer = (): void => { 29 | this.drawerIsOpen = false; 30 | }; 31 | 32 | toggleCollectionModal = ({ open, id }: { open: boolean; id?: string }): void => { 33 | this.collectionDetailModalIsOpen = open; 34 | 35 | if (id) this.rootStore.collectionStore.getDetail({ id }); 36 | }; 37 | 38 | reset = (): void => { 39 | this.collectionDetailModalIsOpen = false; 40 | this.closePlayList(); 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/theme/theme.ts: -------------------------------------------------------------------------------- 1 | import { Input, Select, extendTheme } from '@chakra-ui/react'; 2 | 3 | const theme = extendTheme({ 4 | config: { 5 | initialColorMode: 'dark', 6 | useSystemColorMode: false, 7 | }, 8 | styles: { 9 | global: { 10 | '*': { 11 | boxSizing: 'border-box', 12 | }, 13 | html: { 14 | scrollBehavior: 'smooth', 15 | }, 16 | 'html, body': { 17 | padding: 0, 18 | margin: 0, 19 | fontFamily: 20 | 'Titillium Web, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif', 21 | }, 22 | a: { 23 | color: 'inherit', 24 | textDecoration: 'none', 25 | }, 26 | }, 27 | }, 28 | colors: { 29 | gray: { 30 | 700: '#161b22', 31 | 800: '#0d1117', 32 | }, 33 | }, 34 | shadows: { outline: '0 0 0 3px var(--chakra-colors-teal-300)' }, 35 | }); 36 | 37 | Input.defaultProps = { ...Input.defaultProps, focusBorderColor: 'teal.300' }; 38 | Select.defaultProps = { ...Select.defaultProps, focusBorderColor: 'teal.300' }; 39 | 40 | export default theme; 41 | -------------------------------------------------------------------------------- /src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | type FetchStatus = 'idle' | 'fetching' | 'error' | 'empty' | 'success'; 2 | 3 | type StoreActionResponse = 4 | | { 5 | status?: number; 6 | data?: any; 7 | message?: string; 8 | } 9 | | AxiosResponse; 10 | 11 | type MessageResponse = { message: string }; 12 | 13 | type User = { 14 | id: number; 15 | name: string; 16 | email: string; 17 | }; 18 | 19 | type Podcast = { 20 | title: string; 21 | link: string; 22 | isoDate: string; 23 | enclosure: { 24 | url: string; 25 | length: string; 26 | type: string; 27 | }; 28 | content: string; 29 | itunes: { 30 | summary: string; 31 | duration: string; 32 | image: string; 33 | }; 34 | imageFallback?: string; 35 | }; 36 | 37 | type Collection = { 38 | artistName: string; 39 | collectionId: number; 40 | collectionName: string; 41 | artworkUrl100: string; 42 | artworkUrl600: string; 43 | description?: string; 44 | managingEditor: string; 45 | language: string; 46 | copyright: string; 47 | lastBuildDate: string; 48 | primaryGenreName: string; 49 | genres: string[]; 50 | feedUrl: string; 51 | trackCount: number; 52 | country: string; 53 | items: Podcast[]; 54 | }; 55 | -------------------------------------------------------------------------------- /src/utils/formatDuration.ts: -------------------------------------------------------------------------------- 1 | import formatMillisecondsToHms from './formatMillisecondsToHms'; 2 | import formatSecondsToHms from './formatSecondsToHms'; 3 | 4 | const formatDuration = (duration: string) => { 5 | try { 6 | if (duration.includes(':')) return duration; 7 | 8 | if (duration.length < 8) return String(formatSecondsToHms(Number(duration))); 9 | 10 | return String(formatMillisecondsToHms(Number(duration))); 11 | } catch (error) { 12 | console.warn('formatDuration ERROR'); 13 | console.warn(error); 14 | } 15 | }; 16 | 17 | export default formatDuration; 18 | -------------------------------------------------------------------------------- /src/utils/formatMillisecondsToHms.ts: -------------------------------------------------------------------------------- 1 | const formatMillisecondsToHms = (milliseconds: number) => { 2 | try { 3 | const d = new Date(1000 * Math.round(milliseconds / 1000)); 4 | const pad = (i: number) => ('0' + i).slice(-2); 5 | return d.getUTCHours() + ':' + pad(d.getUTCMinutes()) + ':' + pad(d.getUTCSeconds()); 6 | } catch (error) { 7 | console.warn('formatMillisecondsToHms ERROR'); 8 | return milliseconds; 9 | } 10 | }; 11 | 12 | export default formatMillisecondsToHms; 13 | -------------------------------------------------------------------------------- /src/utils/formatSecondsToHms.ts: -------------------------------------------------------------------------------- 1 | const formatSecondsToHms = (seconds: number) => { 2 | try { 3 | const date = new Date(0); 4 | date.setSeconds(seconds); 5 | return date.toISOString().split('T')[1].split('.')[0]; 6 | } catch (error) { 7 | console.warn('formatSecondsToHms ERROR', error); 8 | return seconds; 9 | } 10 | }; 11 | 12 | export default formatSecondsToHms; 13 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import formatDuration from './formatDuration'; 2 | import formatMillisecondsToHms from './formatMillisecondsToHms'; 3 | import formatSecondsToHms from './formatSecondsToHms'; 4 | import normalizeString from './normalizeString'; 5 | 6 | export { formatDuration, formatMillisecondsToHms, formatSecondsToHms, normalizeString }; 7 | -------------------------------------------------------------------------------- /src/utils/normalizeString.ts: -------------------------------------------------------------------------------- 1 | const normalizeString = (value: string) => value.normalize('NFD').replace(/[^\w\s]/gi, ''); 2 | 3 | export default normalizeString; 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | --------------------------------------------------------------------------------