├── .env ├── .env.production ├── .gitignore ├── .idea ├── .gitignore ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── inspectionProfiles │ └── Project_Default.xml ├── modules.xml ├── prettier.xml ├── scream-db.iml └── vcs.xml ├── .prettierignore ├── .prettierrc.json ├── LICENSE.txt ├── README.md ├── codegen.yml ├── firebase.json ├── graphql └── queries.graphql ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo512.png ├── manifest.json └── robots.txt ├── schema.graphql ├── src ├── App.tsx ├── components │ ├── appbar │ │ ├── LanguagePicker.tsx │ │ └── ScreamAppBar.tsx │ ├── footer │ │ └── ScreamFooter.tsx │ ├── games │ │ ├── SortBySelect.tsx │ │ └── SortDirButton.tsx │ ├── offers │ │ └── OfferTypeFilter.tsx │ ├── router │ │ └── ScreamSwitch.tsx │ ├── settings │ │ └── Settings.tsx │ ├── skeletons │ │ ├── GameCardSkeleton.tsx │ │ └── OfferRowSkeleton.tsx │ ├── util │ │ ├── CustomSelect.tsx │ │ ├── Link.tsx │ │ ├── OverflowBody.tsx │ │ ├── OverflowText.tsx │ │ ├── PaginatedContainer.tsx │ │ ├── ResponsiveBox.tsx │ │ ├── ResponsiveContainer.tsx │ │ ├── SadFace.tsx │ │ └── SearchBar.tsx │ ├── view-items │ │ ├── GameCard.tsx │ │ └── OfferRow.tsx │ └── views │ │ └── TableView.tsx ├── context │ ├── ContextProviders.tsx │ ├── keywords.ts │ └── language.ts ├── generated │ └── graphql.ts ├── hooks │ ├── locale.ts │ └── screen-size.ts ├── index.css ├── index.tsx ├── logo.svg ├── md │ ├── home_en.md │ ├── home_es.md │ ├── home_ru.md │ └── home_zh.md ├── pages │ ├── Games.tsx │ ├── Home.tsx │ └── Offers.tsx ├── react-app-env.d.ts ├── reportWebVitals.ts └── util │ ├── locale.ts │ ├── log.ts │ ├── paths.ts │ ├── query.ts │ ├── storage.ts │ ├── types.ts │ └── typings.d.ts ├── tsconfig.json ├── workers └── epic-cors-proxy.js └── yarn.lock /.env: -------------------------------------------------------------------------------- 1 | REACT_APP_VERSION=$npm_package_version 2 | REACT_APP_CORS_PROXY=https://epic-cors-proxy.acidicoala.workers.dev/ 3 | -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | REACT_APP_CORS_PROXY=https://epic-cors-proxy.acidicoala.workers.dev/ 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more browse ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .eslintcache 26 | debug.log 27 | .env.development 28 | .firebase 29 | .firebase* 30 | firebase-debug.log 31 | 32 | # Workers 33 | workers-site 34 | dist 35 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /../../../../../:\Dev\WebStormProjects\scream-db\.idea/dataSources/ 6 | /dataSources.local.xml 7 | # Editor-based HTTP Client requests 8 | /httpRequests/ 9 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 13 | 14 | 21 | 22 | 29 | 30 | 37 | 38 | 39 | 42 | 43 | 44 | 51 | 52 | 58 | 59 | 67 | 68 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/prettier.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/scream-db.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .idea 2 | graphql 3 | node_modules 4 | public 5 | workers 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "printWidth": 100 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (C) 2022 by Acidicoala 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose 4 | with or without fee is hereby granted. 5 | 6 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 7 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 8 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 9 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS 10 | OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER 11 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF 12 | THIS SOFTWARE. 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🐨 ScreamDB 2 | 3 | ### Welcome to the ScreamDB repository. 4 | 5 | For user-friendly introduction, please check out the web app's [home page](https://scream-db.web.app/). This document is meant for developers. 6 | 7 | ## 🚀 App architecture 8 | 9 | This web was developed and hosted using great technologies such as: 10 | 11 | * [🆁eact](https://reactjs.org/) 12 | * [🆃ypescript](https://www.typescriptlang.org/) 13 | * [🅼aterialUI](https://material-ui.com/) 14 | * [🅶raphQL Code Generator](https://graphql-code-generator.com/) 15 | * [🅲loudflare Workers](https://workers.cloudflare.com/) 16 | * [🅵irebase](https://firebase.google.com/) 17 | 18 | ## 🏢 Hosting 19 | 20 | The web app is hosted on 🔥 Firebase at 21 | 22 | ## 🔐 The CORS issue 23 | 24 | Modern browsers enforce strict [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) policy. That means the web app cannot directly make requests to the Epic Games' [GraphQL endpoint](https://www.epicgames.com/graphql). Furthermore, the endpoint has a whitelist of valid `Referer` header values, which unfortunately is not possible to set using browser's JavaScript. To overcome these issues I have deployed a simple CORS proxy script on the Cloudflare Workers platform. It redirects all request to the actual GraphQL endpoint but modifies the response header [`Access-Control-Allow-Origin`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin) with the domain of this web app. The source of the script is available in this repository: [epic-cors-proxy](./workers/epic-cors-proxy.js). The project is configured to use this proxy in development and production, but it is possible to override them via `.env` files. 25 | 26 | ## 🌐 Localization 27 | 28 | Currently, the following languages are supported: 29 | * English 30 | * Spanish (Credit to @g-yui) 31 | * Russian 32 | * Simplified Chinese (Credit to @Citrinae-Lime) 33 | 34 | The web app localization is defined in [src/util/locale.ts](./src/util/locale.ts). 35 | 36 | Furthermore, home page is rendering localized Markdown documents located at [src/md](./src/md). 37 | 38 | If you wish to contribute a translation for another language, you are free to submit a pull request. 39 | 40 | ## 📜 Available Scripts 41 | 42 | In the project directory, you can run the following commands: 43 | 44 | | Command | Documentation | 45 | |---------------------|-------------------------------------------------| 46 | | `yarn start` | [start @ CRA docs] | 47 | | `yarn build` | [build @ CRA docs] | 48 | | `yarn deploy` | [deploy @ Firebase docs] | 49 | | `yarn generate-sdk` | [graphql-codegen @ GraphQL Code Generator docs] | 50 | 51 | The commands above assume that the corresponding CLI tools have been installed and configured. 52 | 53 | ## 📄 License 54 | This software is licensed under [Zero Clause BSD](https://choosealicense.com/licenses/0bsd/) license, terms of which are available in [LICENSE.txt](./LICENSE.txt). 55 | 56 | [start @ CRA docs]: https://github.com/facebook/create-react-app#npm-start-or-yarn-start 57 | [build @ CRA docs]: https://github.com/facebook/create-react-app#npm-run-build-or-yarn-build 58 | [deploy @ Firebase docs]: https://firebase.google.com/docs/cli#deployment 59 | [graphql-codegen @ GraphQL Code Generator docs]: https://graphql-code-generator.com/docs/plugins/typescript-graphql-request 60 | -------------------------------------------------------------------------------- /codegen.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | schema: "schema.graphql" 3 | documents: "graphql/**/*.graphql" 4 | generates: 5 | src/generated/graphql.ts: 6 | plugins: 7 | - typescript 8 | - typescript-operations 9 | - typescript-resolvers 10 | - typescript-graphql-request 11 | config: 12 | fetcher: graphql-request 13 | scalars: 14 | DateTime: Date 15 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "build", 4 | "ignore": [ 5 | "firebase.json", 6 | "**/.*", 7 | "**/node_modules/**" 8 | ], 9 | "rewrites": [ 10 | { 11 | "source": "**", 12 | "destination": "/index.html" 13 | } 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /graphql/queries.graphql: -------------------------------------------------------------------------------- 1 | query searchGames($keywords: String!, $sortBy: String, $sortDir: String) { 2 | Catalog { 3 | searchStore( 4 | category: "games/edition/base" 5 | count: 1000 6 | keywords: $keywords 7 | sortBy: $sortBy 8 | sortDir: $sortDir 9 | ) { 10 | elements { 11 | id 12 | title 13 | namespace 14 | creationDate 15 | items { 16 | id 17 | namespace 18 | } 19 | keyImages { 20 | type 21 | url 22 | } 23 | } 24 | } 25 | } 26 | } 27 | 28 | query searchOffers($namespace: String!) { 29 | Catalog { 30 | catalogOffers( 31 | namespace: $namespace 32 | params: { 33 | count: 1000, 34 | } 35 | ) { 36 | elements { 37 | id 38 | title 39 | offerType 40 | items { 41 | id 42 | } 43 | keyImages { 44 | type 45 | url 46 | } 47 | } 48 | } 49 | # Get the game title as well 50 | searchStore(category: "games/edition/base", namespace: $namespace){ 51 | elements { 52 | title 53 | catalogNs{ 54 | mappings(pageType: "productHome"){ 55 | pageSlug 56 | } 57 | } 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scream-db", 3 | "version": "1.3.0", 4 | "private": true, 5 | "homepage": "/", 6 | "type": "module", 7 | "dependencies": { 8 | "@graphql-codegen/cli": "^1.21.8", 9 | "@graphql-codegen/typescript": "1.23.0", 10 | "@graphql-codegen/typescript-graphql-request": "^3.0.0", 11 | "@graphql-codegen/typescript-operations": "1.18.4", 12 | "@graphql-codegen/typescript-resolvers": "^1.18.1", 13 | "@material-ui/core": "^4.11.2", 14 | "@material-ui/icons": "^4.11.2", 15 | "@material-ui/lab": "^4.0.0-alpha.57", 16 | "@testing-library/jest-dom": "^5.11.4", 17 | "@testing-library/react": "^12.0.0", 18 | "@testing-library/user-event": "^13.2.1", 19 | "@types/jest": "^26.0.15", 20 | "@types/node": "^16.4.10", 21 | "@types/react": "^17.0.0", 22 | "@types/react-dom": "^17.0.0", 23 | "@types/react-router-dom": "^5.1.7", 24 | "@types/react-syntax-highlighter": "^13.5.0", 25 | "graphql": "^15.4.0", 26 | "graphql-request": "^3.4.0", 27 | "material-ui-popup-state": "^1.7.1", 28 | "react": "^17.0.1", 29 | "react-animate-height": "^2.0.23", 30 | "react-dom": "^17.0.1", 31 | "react-markdown": "^6.0.3", 32 | "react-router-dom": "^5.2.0", 33 | "react-scripts": "4.0.3", 34 | "react-syntax-highlighter": "^15.4.3", 35 | "typescript": "^4.1.3", 36 | "web-vitals": "^2.1.0" 37 | }, 38 | "devDependencies": { 39 | "firebase-tools": "^10.2.1", 40 | "prettier": "2.5.1" 41 | }, 42 | "scripts": { 43 | "start": "react-scripts start", 44 | "build": "react-scripts build", 45 | "deploy": "firebase deploy", 46 | "generate-sdk": "graphql-codegen --config codegen.yml" 47 | }, 48 | "eslintConfig": { 49 | "extends": [ 50 | "react-app", 51 | "react-app/jest" 52 | ], 53 | "plugins": [ 54 | "react-hooks" 55 | ], 56 | "overrides": [ 57 | { 58 | "files": [ 59 | "**/*.ts?(x)" 60 | ], 61 | "rules": { 62 | "react-hooks/exhaustive-deps": "off" 63 | } 64 | } 65 | ] 66 | }, 67 | "browserslist": { 68 | "production": [ 69 | ">0.2%", 70 | "not dead", 71 | "not op_mini all" 72 | ], 73 | "development": [ 74 | "last 1 chrome version", 75 | "last 1 firefox version", 76 | "last 1 safari version" 77 | ] 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acidicoala/ScreamDB/a16a6c9c868e9afd27181d1865e52929d5d756c5/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | ScreamDB 13 | 14 | 15 | 16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/acidicoala/ScreamDB/a16a6c9c868e9afd27181d1865e52929d5d756c5/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "ScreamDB", 3 | "name": "ScreamDB", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#2F2F2F", 14 | "background_color": "#2F2F2F" 15 | } 16 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /schema.graphql: -------------------------------------------------------------------------------- 1 | schema { 2 | query: Query 3 | } 4 | 5 | type Query { 6 | Catalog: CatalogQuery 7 | } 8 | 9 | type CatalogQuery { 10 | searchStore(category: String, count: Int, namespace: String, keywords: String, sortBy: String, sortDir: String): Elements! 11 | catalogOffers(namespace: String!, params: CatalogOffersParams): Elements! 12 | } 13 | 14 | input CatalogOffersParams { 15 | count: Int 16 | } 17 | 18 | type Elements { 19 | elements: [Element!]! 20 | } 21 | 22 | type Mapping{ 23 | pageSlug: String! 24 | } 25 | type CatalogNs { 26 | mappings(pageType: String): [Mapping!]! 27 | } 28 | 29 | type Element { 30 | id: String! 31 | title: String! 32 | namespace: String! 33 | offerType: OfferType! 34 | items: [Item!]! 35 | keyImages: [KeyImage!]! 36 | creationDate: String! 37 | catalogNs: CatalogNs! 38 | } 39 | 40 | enum OfferType { 41 | ADD_ON 42 | BASE_GAME 43 | BUNDLE 44 | EDITION 45 | DLC 46 | OTHERS 47 | UNLOCKABLE 48 | } 49 | 50 | type Item { 51 | id: String! 52 | title: String! 53 | namespace: String! 54 | } 55 | 56 | type KeyImage { 57 | type: KeyImageType! 58 | url: String 59 | } 60 | 61 | enum KeyImageType { 62 | OfferImageTall 63 | OfferImageWide 64 | Thumbnail 65 | CodeRedemption_340x440 66 | DieselStoreFrontTall 67 | DieselStoreFrontWide 68 | } 69 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | createTheme, 4 | CssBaseline, 5 | responsiveFontSizes, 6 | ThemeOptions, 7 | ThemeProvider, 8 | } from "@material-ui/core"; 9 | import { ScreamAppBar } from "./components/appbar/ScreamAppBar"; 10 | import { ScreamSwitch } from "./components/router/ScreamSwitch"; 11 | import { BrowserRouter as Router } from "react-router-dom"; 12 | import { ContextProviders } from "./context/ContextProviders"; 13 | import { OverflowBody } from "./components/util/OverflowBody"; 14 | import { ResponsiveContainer } from "./components/util/ResponsiveContainer"; 15 | 16 | const theme = responsiveFontSizes( 17 | createTheme({ 18 | palette: { 19 | type: "dark", 20 | primary: { 21 | main: "#2e7d32", 22 | contrastText: "#FFF", 23 | }, 24 | secondary: { 25 | main: "#FFF", 26 | }, 27 | background: { 28 | default: "#2F2F2F", 29 | }, 30 | }, 31 | } as ThemeOptions) 32 | ); 33 | 34 | function App() { 35 | return ( 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | {/**/} 47 | 48 | 49 | 50 | 51 | 52 | ); 53 | } 54 | 55 | export default App; 56 | -------------------------------------------------------------------------------- /src/components/appbar/LanguagePicker.tsx: -------------------------------------------------------------------------------- 1 | import { rawLocale } from "../../util/locale"; 2 | import { CustomSelect } from "../util/CustomSelect"; 3 | import React from "react"; 4 | import { useLanguage } from "../../context/language"; 5 | import { KeyboardArrowDown, Translate } from "@material-ui/icons"; 6 | import { useLocale } from "../../hooks/locale"; 7 | import { useXS } from "../../hooks/screen-size"; 8 | import { ValidLanguage } from "../../util/types"; 9 | 10 | export function LanguagePicker() { 11 | const { setLang } = useLanguage(); 12 | const { locale } = useLocale(); 13 | const xs = useXS(); 14 | 15 | const languages = [ 16 | { key: "en", text: rawLocale.lang.en }, 17 | { key: "es", text: rawLocale.lang.es }, 18 | { key: "ru", text: rawLocale.lang.ru }, 19 | { key: "zh", text: rawLocale.lang.zh }, 20 | ]; 21 | 22 | return ( 23 | } 26 | endIcon={} 27 | items={languages} 28 | onItemSelect={(item) => setLang(item.key as ValidLanguage)} 29 | children={!xs && locale.lang} 30 | /> 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/components/appbar/ScreamAppBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { 3 | AppBar, 4 | Box, 5 | Button, 6 | ButtonGroup, 7 | Container, 8 | createStyles, 9 | Divider, 10 | Hidden, 11 | IconButton, 12 | makeStyles, 13 | Toolbar, 14 | Typography, 15 | } from "@material-ui/core"; 16 | import { maxWidth } from "../../util/storage"; 17 | import { path } from "../../util/paths"; 18 | import { useLocale } from "../../hooks/locale"; 19 | import { useXS } from "../../hooks/screen-size"; 20 | import { LanguagePicker } from "./LanguagePicker"; 21 | import { Link, useHistory } from "react-router-dom"; 22 | import AnimateHeight from "react-animate-height"; 23 | import { Menu } from "@material-ui/icons"; 24 | import { ScreamLink } from "../util/Link"; 25 | import { useKeywords } from "../../context/keywords"; 26 | import { SearchBar } from "../util/SearchBar"; 27 | 28 | const useStyles = makeStyles(() => 29 | createStyles({ 30 | toolbar: { 31 | padding: 0, 32 | }, 33 | }) 34 | ); 35 | 36 | export function ScreamAppBar() { 37 | const history = useHistory(); 38 | const { locale } = useLocale(); 39 | const classes = useStyles(); 40 | const { setKeywords } = useKeywords(); 41 | const xs = useXS(); 42 | 43 | const [open, setOpen] = useState(false); 44 | 45 | const NavButtons = () => ( 46 | 47 | { 48 | 19 | 20 | {items?.map((item) => ( 21 | { 27 | popupState.close(); 28 | onItemSelect?.(item); 29 | }} 30 | > 31 | 32 | {item.text} 33 | 34 | 35 | ))} 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/components/util/Link.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link, LinkProps } from "react-router-dom"; 3 | import { createStyles, makeStyles } from "@material-ui/core"; 4 | 5 | const useStyles = makeStyles(() => 6 | createStyles({ 7 | link: { 8 | color: "white", 9 | textDecoration: "none", 10 | "&:hover": { 11 | textDecoration: "underline", 12 | }, 13 | }, 14 | }) 15 | ); 16 | 17 | export function ScreamLink(props: LinkProps) { 18 | const { link } = useStyles(); 19 | return ; 20 | } 21 | -------------------------------------------------------------------------------- /src/components/util/OverflowBody.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren } from "react"; 2 | import { createStyles, makeStyles } from "@material-ui/core"; 3 | 4 | const useStyles = makeStyles(({ breakpoints }) => 5 | createStyles({ 6 | body: { 7 | [breakpoints.up("sm")]: { 8 | overflowY: "scroll", 9 | height: `calc(100vh - 64px)`, 10 | }, 11 | }, 12 | }) 13 | ); 14 | 15 | export function OverflowBody(props: PropsWithChildren<{}>) { 16 | const { body } = useStyles(); 17 | return
; 18 | } 19 | -------------------------------------------------------------------------------- /src/components/util/OverflowText.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties, PropsWithChildren } from "react"; 2 | import { createStyles, makeStyles } from "@material-ui/core"; 3 | 4 | const useStyles = (lines: number) => 5 | makeStyles(() => 6 | createStyles({ 7 | text: { 8 | overflow: "hidden", 9 | textOverflow: "ellipsis", 10 | display: "-webkit-box", 11 | "-webkit-line-clamp": lines, 12 | "-webkit-box-orient": "vertical", 13 | }, 14 | }) 15 | )(); 16 | 17 | export function OverflowText( 18 | props: PropsWithChildren<{ 19 | lines: number; 20 | style: CSSProperties; 21 | }> 22 | ) { 23 | const { lines, style, children } = props; 24 | const { text } = useStyles(lines); 25 | 26 | return ( 27 |
28 | {children} 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/util/PaginatedContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren, useRef, useState } from "react"; 2 | import { Box, TablePagination } from "@material-ui/core"; 3 | import { readProp, writeProp } from "../../util/storage"; 4 | import { createTheme, ThemeProvider } from "@material-ui/core/styles"; 5 | import { ruRU, enUS, esES, zhCN } from "@material-ui/core/locale"; 6 | import { useLanguage } from "../../context/language"; 7 | 8 | const PROP_KEY = "item_per_page"; 9 | 10 | export function usePaginationControls(items?: T[]) { 11 | const storedItemsPerPage = Number(readProp("item_per_page", "10")); 12 | const [itemsPerPage, setItemsPerPage] = useState( 13 | storedItemsPerPage ? storedItemsPerPage : 10 14 | ); 15 | const [page, setPage] = useState(0); 16 | 17 | return { 18 | itemsPerPage: itemsPerPage, 19 | setItemsPerPage: setItemsPerPage, 20 | page: page, 21 | setPage: setPage, 22 | items: items, 23 | pageItems: () => items?.slice(page * itemsPerPage, (page + 1) * itemsPerPage), // slice for pagination 24 | }; 25 | } 26 | 27 | export function PaginatedContainer( 28 | props: PropsWithChildren<{ 29 | controls: ReturnType; 30 | }> 31 | ) { 32 | const { controls, children } = props; 33 | const { items, itemsPerPage, page, setItemsPerPage, setPage } = controls; 34 | 35 | const { lang } = useLanguage(); 36 | 37 | const locale = { 38 | en: enUS, 39 | es: esES, 40 | ru: ruRU, 41 | zh: zhCN, 42 | }[lang]; 43 | 44 | const containerRef = useRef(null); 45 | 46 | return ( 47 | 48 | {children} 49 | 50 | {items && ( 51 | createTheme(outerTheme, locale)}> 52 | { 59 | containerRef?.current?.scrollIntoView(); 60 | setPage(page); 61 | }} 62 | onChangeRowsPerPage={(event) => { 63 | setItemsPerPage(parseInt(event.target.value, 10)); 64 | writeProp(PROP_KEY, event.target.value); 65 | setPage(0); 66 | }} 67 | /> 68 | 69 | )} 70 | 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /src/components/util/ResponsiveBox.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren } from "react"; 2 | import { Box, BoxProps, Theme, useMediaQuery } from "@material-ui/core"; 3 | import { Breakpoint } from "@material-ui/core/styles/createBreakpoints"; 4 | 5 | export function ResponsiveBox( 6 | props: PropsWithChildren< 7 | { 8 | breakpoint: Breakpoint; 9 | } & BoxProps 10 | > 11 | ) { 12 | const { breakpoint, children, ...boxProps } = props; 13 | const responsive = useMediaQuery((theme) => theme.breakpoints.down(breakpoint)); 14 | 15 | return ( 16 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/util/ResponsiveContainer.tsx: -------------------------------------------------------------------------------- 1 | import { Container, createStyles, makeStyles } from "@material-ui/core"; 2 | import { ReactNode } from "react"; 3 | 4 | const useStyles = makeStyles(({ breakpoints }) => 5 | createStyles({ 6 | container: { 7 | // margin: 'auto', 8 | width: "100%", 9 | maxWidth: "100%", 10 | [breakpoints.up("sm")]: { 11 | maxWidth: breakpoints.values.sm, 12 | }, 13 | [breakpoints.up("md")]: { 14 | maxWidth: breakpoints.values.md, 15 | }, 16 | [breakpoints.up("lg")]: { 17 | maxWidth: breakpoints.values.lg, 18 | }, 19 | }, 20 | }) 21 | ); 22 | 23 | export function ResponsiveContainer(props: { children: ReactNode }) { 24 | const classes = useStyles(); 25 | return {props.children}} />; 26 | } 27 | -------------------------------------------------------------------------------- /src/components/util/SadFace.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Typography } from "@material-ui/core"; 2 | import { SentimentDissatisfied } from "@material-ui/icons"; 3 | import { PropsWithChildren } from "react"; 4 | 5 | export function SadFace(props: PropsWithChildren<{}>) { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/components/util/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties, useState } from "react"; 2 | import { createStyles, Divider, IconButton, InputBase, makeStyles, Paper } from "@material-ui/core"; 3 | import { ArrowForward, Clear, Search } from "@material-ui/icons"; 4 | 5 | const useStyles = makeStyles(({ breakpoints, spacing }) => 6 | createStyles({ 7 | root: { 8 | padding: "2px 4px", 9 | display: "flex", 10 | alignItems: "center", 11 | background: "rgb(0,0,0,0.2)", 12 | boxShadow: "none", 13 | width: "100%", 14 | maxWidth: spacing(50), 15 | [breakpoints.down("xs")]: { 16 | maxWidth: "100%", 17 | }, 18 | }, 19 | input: { 20 | marginLeft: spacing(1), 21 | flex: 1, 22 | }, 23 | iconButton: { 24 | padding: spacing(1), 25 | }, 26 | divider: { 27 | height: 28, 28 | margin: 4, 29 | }, 30 | }) 31 | ); 32 | 33 | export function SearchBar(props: { 34 | placeholder: string; 35 | onClear?: (query: "") => void; 36 | onSearch: (query: string) => void; 37 | style?: CSSProperties; 38 | }) { 39 | const { placeholder, onClear, onSearch, style } = props; 40 | const classes = useStyles(); 41 | const [searchQuery, setSearchQuery] = useState(""); 42 | 43 | return ( 44 | 45 | 46 | setSearchQuery(event.target.value)} 50 | placeholder={placeholder} 51 | onKeyPress={(event) => { 52 | if (event.key === "Enter") { 53 | onSearch(searchQuery); 54 | event.preventDefault(); 55 | } 56 | }} 57 | /> 58 | { 62 | onClear?.(""); 63 | setSearchQuery(""); 64 | }} 65 | > 66 | 67 | 68 | 69 | onSearch(searchQuery)}> 70 | 71 | 72 | 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /src/components/view-items/GameCard.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Card } from "@material-ui/core"; 2 | import { CSSProperties, useState } from "react"; 3 | import { OverflowText } from "../util/OverflowText"; 4 | import { Link } from "react-router-dom"; 5 | import { path } from "../../util/paths"; 6 | import { GameCardData } from "../../util/types"; 7 | 8 | export function GameCard(props: { data: GameCardData; style?: CSSProperties }) { 9 | const { data, style } = props; 10 | const [raised, setRaised] = useState(false); 11 | 12 | return ( 13 | 14 | setRaised(true)} 18 | onMouseLeave={() => setRaised(false)} 19 | > 20 | 21 | {data.title} 28 | 29 | 30 | 31 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/components/view-items/OfferRow.tsx: -------------------------------------------------------------------------------- 1 | import { OfferRowData } from "../../util/types"; 2 | import React, { useState } from "react"; 3 | import { 4 | Box, 5 | Collapse, 6 | IconButton, 7 | Popover, 8 | Table, 9 | TableBody, 10 | TableCell, 11 | TableRow, 12 | } from "@material-ui/core"; 13 | import { KeyboardArrowDown, KeyboardArrowUp, Panorama } from "@material-ui/icons"; 14 | import { useLocale } from "../../hooks/locale"; 15 | import { bindTrigger, usePopupState } from "material-ui-popup-state/hooks"; 16 | import { bindPopover } from "material-ui-popup-state"; 17 | 18 | export function OfferRow(props: { data: OfferRowData }) { 19 | const { data } = props; 20 | const [open, setOpen] = useState(false); 21 | const { locale } = useLocale(); 22 | 23 | const popupState = usePopupState({ 24 | variant: "popover", 25 | popupId: "offer-img", 26 | }); 27 | 28 | return ( 29 | <> 30 | 31 | 32 | {data.items.length > 1 && ( 33 | setOpen(!open)}> 34 | {open ? : } 35 | 36 | )} 37 | 38 | 39 | 40 | {data.image ? ( 41 | {""} 49 | ) : ( 50 | 51 | )} 52 | 53 | 65 | 66 | {data.title} 75 | 76 | 77 | 78 | {data.title} 79 | {data.items.length === 1 ? data.items[0].id : locale.multiple_items} 80 | {data.offerType} 81 | 82 | {data.items.length > 1 && ( 83 | 84 | 85 | 86 | 87 | 88 | {data.items.map((item) => ( 89 | 90 | 91 | {item.title} 92 | {item.id} 93 | 94 | ))} 95 | 96 |
97 |
98 |
99 |
100 | )} 101 | 102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /src/components/views/TableView.tsx: -------------------------------------------------------------------------------- 1 | import { OfferRowData } from "../../util/types"; 2 | import { 3 | Box, 4 | Table, 5 | TableBody, 6 | TableCell, 7 | TableContainer, 8 | TableHead, 9 | TableRow, 10 | } from "@material-ui/core"; 11 | import { useLocale } from "../../hooks/locale"; 12 | import { OfferRowSkeleton } from "../skeletons/OfferRowSkeleton"; 13 | import { usePaginationControls } from "../util/PaginatedContainer"; 14 | import React from "react"; 15 | import { SearchBar } from "../util/SearchBar"; 16 | import { OfferRow } from "../view-items/OfferRow"; 17 | import { OfferType } from "../../generated/graphql"; 18 | import { OfferTypeFilter } from "../offers/OfferTypeFilter"; 19 | import { ResponsiveBox } from "../util/ResponsiveBox"; 20 | 21 | const usePaginationControlsWrapper = () => usePaginationControls(); 22 | 23 | export function TableView(props: { 24 | pagination: ReturnType; 25 | setFilterID: (idFilter: string) => void; 26 | offerTypeFilters: Record; 27 | setOfferTypeFilters: (typeFilters: Record) => void; 28 | }) { 29 | const { pagination, setFilterID, offerTypeFilters, setOfferTypeFilters } = props; 30 | const { locale } = useLocale(); 31 | 32 | function onSearch(query: string) { 33 | setFilterID(query); 34 | pagination.setPage(0); 35 | } 36 | 37 | return ( 38 | 39 | 40 | 41 | 42 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | {locale.image} 54 | {locale.title} 55 | {locale.id} 56 | {locale.offer_type} 57 | 58 | 59 | 60 | {pagination.pageItems()?.map((item) => ) ?? 61 | [...Array(pagination.itemsPerPage).keys()].map((it) => )} 62 | 63 |
64 |
65 |
66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/context/ContextProviders.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren, useState } from "react"; 2 | import { LanguageContext } from "./language"; 3 | import { KeywordsContext } from "./keywords"; 4 | import { readProp, writeProp } from "../util/storage"; 5 | import { ValidLanguage } from "../util/types"; 6 | 7 | export function ContextProviders(props: PropsWithChildren<{}>) { 8 | let storedLang = readProp("lang", "en"); 9 | if (!["en", "es", "ru", "zh"].includes(storedLang)) { 10 | storedLang = "en"; 11 | writeProp("lang", "en"); 12 | } 13 | 14 | const [language, setLanguage] = useState(storedLang as ValidLanguage); 15 | const langValue = { 16 | lang: language, 17 | setLang: (key: ValidLanguage) => { 18 | setLanguage(key); 19 | writeProp("lang", key); 20 | }, 21 | }; 22 | 23 | const [keywords, setKeywords] = useState(""); 24 | const keywordsValue = { 25 | keywords: keywords, 26 | setKeywords: setKeywords, 27 | }; 28 | 29 | return ( 30 | 31 | {props.children} 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/context/keywords.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | 3 | export const KeywordsContext = createContext<{ 4 | keywords: string; 5 | setKeywords: (key: string) => void; 6 | }>({ 7 | keywords: "", 8 | setKeywords: () => {}, 9 | }); 10 | export const useKeywords = () => useContext(KeywordsContext); 11 | -------------------------------------------------------------------------------- /src/context/language.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | import { ValidLanguage } from "../util/types"; 3 | 4 | export const LanguageContext = createContext<{ 5 | lang: ValidLanguage; 6 | setLang: (key: ValidLanguage) => void; 7 | }>({ 8 | lang: "en", 9 | setLang: () => {}, 10 | }); 11 | export const useLanguage = () => useContext(LanguageContext); 12 | -------------------------------------------------------------------------------- /src/generated/graphql.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLResolveInfo } from "graphql"; 2 | import { GraphQLClient } from "graphql-request"; 3 | import * as Dom from "graphql-request/dist/types.dom"; 4 | import gql from "graphql-tag"; 5 | 6 | export type Maybe = T | null; 7 | export type Exact = { [K in keyof T]: T[K] }; 8 | export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; 9 | export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; 10 | export type RequireFields = 11 | { [X in Exclude]?: T[X] } 12 | & { [P in K]-?: NonNullable }; 13 | /** All built-in and custom scalars, mapped to their actual values */ 14 | export type Scalars = { 15 | ID: string; 16 | String: string; 17 | Boolean: boolean; 18 | Int: number; 19 | Float: number; 20 | }; 21 | 22 | export type CatalogNs = { 23 | __typename?: "CatalogNs"; 24 | mappings: Array; 25 | }; 26 | 27 | 28 | export type CatalogNsMappingsArgs = { 29 | pageType?: Maybe; 30 | }; 31 | 32 | export type CatalogOffersParams = { 33 | count?: Maybe; 34 | }; 35 | 36 | export type CatalogQuery = { 37 | __typename?: "CatalogQuery"; 38 | searchStore: Elements; 39 | catalogOffers: Elements; 40 | }; 41 | 42 | 43 | export type CatalogQuerySearchStoreArgs = { 44 | category?: Maybe; 45 | count?: Maybe; 46 | namespace?: Maybe; 47 | keywords?: Maybe; 48 | sortBy?: Maybe; 49 | sortDir?: Maybe; 50 | }; 51 | 52 | 53 | export type CatalogQueryCatalogOffersArgs = { 54 | namespace: Scalars["String"]; 55 | params?: Maybe; 56 | }; 57 | 58 | export type Element = { 59 | __typename?: "Element"; 60 | id: Scalars["String"]; 61 | title: Scalars["String"]; 62 | namespace: Scalars["String"]; 63 | offerType: OfferType; 64 | items: Array; 65 | keyImages: Array; 66 | creationDate: Scalars["String"]; 67 | catalogNs: CatalogNs; 68 | }; 69 | 70 | export type Elements = { 71 | __typename?: "Elements"; 72 | elements: Array; 73 | }; 74 | 75 | export type Item = { 76 | __typename?: "Item"; 77 | id: Scalars["String"]; 78 | title: Scalars["String"]; 79 | namespace: Scalars["String"]; 80 | }; 81 | 82 | export type KeyImage = { 83 | __typename?: "KeyImage"; 84 | type: KeyImageType; 85 | url?: Maybe; 86 | }; 87 | 88 | export enum KeyImageType { 89 | OfferImageTall = "OfferImageTall", 90 | OfferImageWide = "OfferImageWide", 91 | Thumbnail = "Thumbnail", 92 | CodeRedemption_340x440 = "CodeRedemption_340x440", 93 | DieselStoreFrontTall = "DieselStoreFrontTall", 94 | DieselStoreFrontWide = "DieselStoreFrontWide" 95 | } 96 | 97 | export type Mapping = { 98 | __typename?: "Mapping"; 99 | pageSlug: Scalars["String"]; 100 | }; 101 | 102 | export enum OfferType { 103 | AddOn = "ADD_ON", 104 | BaseGame = "BASE_GAME", 105 | Bundle = "BUNDLE", 106 | Edition = "EDITION", 107 | Dlc = "DLC", 108 | Others = "OTHERS", 109 | Unlockable = "UNLOCKABLE" 110 | } 111 | 112 | export type Query = { 113 | __typename?: "Query"; 114 | Catalog?: Maybe; 115 | }; 116 | 117 | export type SearchGamesQueryVariables = Exact<{ 118 | keywords: Scalars["String"]; 119 | sortBy?: Maybe; 120 | sortDir?: Maybe; 121 | }>; 122 | 123 | 124 | export type SearchGamesQuery = ( 125 | { __typename?: "Query" } 126 | & { 127 | Catalog?: Maybe<( 128 | { __typename?: "CatalogQuery" } 129 | & { 130 | searchStore: ( 131 | { __typename?: "Elements" } 132 | & { 133 | elements: Array<( 134 | { __typename?: "Element" } 135 | & Pick 136 | & { 137 | items: Array<( 138 | { __typename?: "Item" } 139 | & Pick 140 | )>, keyImages: Array<( 141 | { __typename?: "KeyImage" } 142 | & Pick 143 | )> 144 | } 145 | )> 146 | } 147 | ) 148 | } 149 | )> 150 | } 151 | ); 152 | 153 | export type SearchOffersQueryVariables = Exact<{ 154 | namespace: Scalars["String"]; 155 | }>; 156 | 157 | 158 | export type SearchOffersQuery = ( 159 | { __typename?: "Query" } 160 | & { 161 | Catalog?: Maybe<( 162 | { __typename?: "CatalogQuery" } 163 | & { 164 | catalogOffers: ( 165 | { __typename?: "Elements" } 166 | & { 167 | elements: Array<( 168 | { __typename?: "Element" } 169 | & Pick 170 | & { 171 | items: Array<( 172 | { __typename?: "Item" } 173 | & Pick 174 | )>, keyImages: Array<( 175 | { __typename?: "KeyImage" } 176 | & Pick 177 | )> 178 | } 179 | )> 180 | } 181 | ), searchStore: ( 182 | { __typename?: "Elements" } 183 | & { 184 | elements: Array<( 185 | { __typename?: "Element" } 186 | & Pick 187 | & { 188 | catalogNs: ( 189 | { __typename?: "CatalogNs" } 190 | & { 191 | mappings: Array<( 192 | { __typename?: "Mapping" } 193 | & Pick 194 | )> 195 | } 196 | ) 197 | } 198 | )> 199 | } 200 | ) 201 | } 202 | )> 203 | } 204 | ); 205 | 206 | 207 | export type ResolverTypeWrapper = Promise | T; 208 | 209 | 210 | export type ResolverWithResolve = { 211 | resolve: ResolverFn; 212 | }; 213 | 214 | export type LegacyStitchingResolver = { 215 | fragment: string; 216 | resolve: ResolverFn; 217 | }; 218 | 219 | export type NewStitchingResolver = { 220 | selectionSet: string; 221 | resolve: ResolverFn; 222 | }; 223 | export type StitchingResolver = 224 | LegacyStitchingResolver 225 | | NewStitchingResolver; 226 | export type Resolver = 227 | | ResolverFn 228 | | ResolverWithResolve 229 | | StitchingResolver; 230 | 231 | export type ResolverFn = ( 232 | parent: TParent, 233 | args: TArgs, 234 | context: TContext, 235 | info: GraphQLResolveInfo 236 | ) => Promise | TResult; 237 | 238 | export type SubscriptionSubscribeFn = ( 239 | parent: TParent, 240 | args: TArgs, 241 | context: TContext, 242 | info: GraphQLResolveInfo 243 | ) => AsyncIterator | Promise>; 244 | 245 | export type SubscriptionResolveFn = ( 246 | parent: TParent, 247 | args: TArgs, 248 | context: TContext, 249 | info: GraphQLResolveInfo 250 | ) => TResult | Promise; 251 | 252 | export interface SubscriptionSubscriberObject { 253 | subscribe: SubscriptionSubscribeFn<{ [key in TKey]: TResult }, TParent, TContext, TArgs>; 254 | resolve?: SubscriptionResolveFn; 255 | } 256 | 257 | export interface SubscriptionResolverObject { 258 | subscribe: SubscriptionSubscribeFn; 259 | resolve: SubscriptionResolveFn; 260 | } 261 | 262 | export type SubscriptionObject = 263 | | SubscriptionSubscriberObject 264 | | SubscriptionResolverObject; 265 | 266 | export type SubscriptionResolver = 267 | | ((...args: any[]) => SubscriptionObject) 268 | | SubscriptionObject; 269 | 270 | export type TypeResolveFn = ( 271 | parent: TParent, 272 | context: TContext, 273 | info: GraphQLResolveInfo 274 | ) => Maybe | Promise>; 275 | 276 | export type IsTypeOfResolverFn = (obj: T, context: TContext, info: GraphQLResolveInfo) => boolean | Promise; 277 | 278 | export type NextResolverFn = () => Promise; 279 | 280 | export type DirectiveResolverFn = ( 281 | next: NextResolverFn, 282 | parent: TParent, 283 | args: TArgs, 284 | context: TContext, 285 | info: GraphQLResolveInfo 286 | ) => TResult | Promise; 287 | 288 | /** Mapping between all available schema types and the resolvers types */ 289 | export type ResolversTypes = { 290 | CatalogNs: ResolverTypeWrapper; 291 | String: ResolverTypeWrapper; 292 | CatalogOffersParams: CatalogOffersParams; 293 | Int: ResolverTypeWrapper; 294 | CatalogQuery: ResolverTypeWrapper; 295 | Element: ResolverTypeWrapper; 296 | Elements: ResolverTypeWrapper; 297 | Item: ResolverTypeWrapper; 298 | KeyImage: ResolverTypeWrapper; 299 | KeyImageType: KeyImageType; 300 | Mapping: ResolverTypeWrapper; 301 | OfferType: OfferType; 302 | Query: ResolverTypeWrapper<{}>; 303 | Boolean: ResolverTypeWrapper; 304 | }; 305 | 306 | /** Mapping between all available schema types and the resolvers parents */ 307 | export type ResolversParentTypes = { 308 | CatalogNs: CatalogNs; 309 | String: Scalars["String"]; 310 | CatalogOffersParams: CatalogOffersParams; 311 | Int: Scalars["Int"]; 312 | CatalogQuery: CatalogQuery; 313 | Element: Element; 314 | Elements: Elements; 315 | Item: Item; 316 | KeyImage: KeyImage; 317 | Mapping: Mapping; 318 | Query: {}; 319 | Boolean: Scalars["Boolean"]; 320 | }; 321 | 322 | export type CatalogNsResolvers = { 323 | mappings?: Resolver, ParentType, ContextType, RequireFields>; 324 | __isTypeOf?: IsTypeOfResolverFn; 325 | }; 326 | 327 | export type CatalogQueryResolvers = { 328 | searchStore?: Resolver>; 329 | catalogOffers?: Resolver>; 330 | __isTypeOf?: IsTypeOfResolverFn; 331 | }; 332 | 333 | export type ElementResolvers = { 334 | id?: Resolver; 335 | title?: Resolver; 336 | namespace?: Resolver; 337 | offerType?: Resolver; 338 | items?: Resolver, ParentType, ContextType>; 339 | keyImages?: Resolver, ParentType, ContextType>; 340 | creationDate?: Resolver; 341 | catalogNs?: Resolver; 342 | __isTypeOf?: IsTypeOfResolverFn; 343 | }; 344 | 345 | export type ElementsResolvers = { 346 | elements?: Resolver, ParentType, ContextType>; 347 | __isTypeOf?: IsTypeOfResolverFn; 348 | }; 349 | 350 | export type ItemResolvers = { 351 | id?: Resolver; 352 | title?: Resolver; 353 | namespace?: Resolver; 354 | __isTypeOf?: IsTypeOfResolverFn; 355 | }; 356 | 357 | export type KeyImageResolvers = { 358 | type?: Resolver; 359 | url?: Resolver, ParentType, ContextType>; 360 | __isTypeOf?: IsTypeOfResolverFn; 361 | }; 362 | 363 | export type MappingResolvers = { 364 | pageSlug?: Resolver; 365 | __isTypeOf?: IsTypeOfResolverFn; 366 | }; 367 | 368 | export type QueryResolvers = { 369 | Catalog?: Resolver, ParentType, ContextType>; 370 | }; 371 | 372 | export type Resolvers = { 373 | CatalogNs?: CatalogNsResolvers; 374 | CatalogQuery?: CatalogQueryResolvers; 375 | Element?: ElementResolvers; 376 | Elements?: ElementsResolvers; 377 | Item?: ItemResolvers; 378 | KeyImage?: KeyImageResolvers; 379 | Mapping?: MappingResolvers; 380 | Query?: QueryResolvers; 381 | }; 382 | 383 | 384 | /** 385 | * @deprecated 386 | * Use "Resolvers" root object instead. If you wish to get "IResolvers", add "typesPrefix: I" to your config. 387 | */ 388 | export type IResolvers = Resolvers; 389 | 390 | 391 | export const SearchGamesDocument = gql` 392 | query searchGames($keywords: String!, $sortBy: String, $sortDir: String) { 393 | Catalog { 394 | searchStore( 395 | category: "games/edition/base" 396 | count: 1000 397 | keywords: $keywords 398 | sortBy: $sortBy 399 | sortDir: $sortDir 400 | ) { 401 | elements { 402 | id 403 | title 404 | namespace 405 | creationDate 406 | items { 407 | id 408 | namespace 409 | } 410 | keyImages { 411 | type 412 | url 413 | } 414 | } 415 | } 416 | } 417 | } 418 | `; 419 | export const SearchOffersDocument = gql` 420 | query searchOffers($namespace: String!) { 421 | Catalog { 422 | catalogOffers(namespace: $namespace, params: {count: 1000}) { 423 | elements { 424 | id 425 | title 426 | offerType 427 | items { 428 | id 429 | } 430 | keyImages { 431 | type 432 | url 433 | } 434 | } 435 | } 436 | searchStore(category: "games/edition/base", namespace: $namespace) { 437 | elements { 438 | title 439 | catalogNs { 440 | mappings(pageType: "productHome") { 441 | pageSlug 442 | } 443 | } 444 | } 445 | } 446 | } 447 | } 448 | `; 449 | 450 | export type SdkFunctionWrapper = (action: (requestHeaders?: Record) => Promise, operationName: string) => Promise; 451 | 452 | 453 | const defaultWrapper: SdkFunctionWrapper = (action, _operationName) => action(); 454 | 455 | export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = defaultWrapper) { 456 | return { 457 | searchGames(variables: SearchGamesQueryVariables, requestHeaders?: Dom.RequestInit["headers"]): Promise { 458 | return withWrapper((wrappedRequestHeaders) => client.request(SearchGamesDocument, variables, { ...requestHeaders, ...wrappedRequestHeaders }), "searchGames"); 459 | }, 460 | searchOffers(variables: SearchOffersQueryVariables, requestHeaders?: Dom.RequestInit["headers"]): Promise { 461 | return withWrapper((wrappedRequestHeaders) => client.request(SearchOffersDocument, variables, { ...requestHeaders, ...wrappedRequestHeaders }), "searchOffers"); 462 | } 463 | }; 464 | } 465 | 466 | export type Sdk = ReturnType; -------------------------------------------------------------------------------- /src/hooks/locale.ts: -------------------------------------------------------------------------------- 1 | import { useLanguage } from "../context/language"; 2 | import { rawLocale } from "../util/locale"; 3 | 4 | export function useLocale() { 5 | const { lang } = useLanguage(); 6 | 7 | return { 8 | locale: new Proxy>( 9 | // @ts-ignore 10 | rawLocale, 11 | { 12 | get(target, name, receiver) { 13 | let rv = Reflect.get(target, name, receiver); 14 | if (name in target) rv = rv[lang] ?? rv["en"]; 15 | return rv; 16 | }, 17 | set(): boolean { 18 | return false; 19 | }, 20 | } 21 | ), 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /src/hooks/screen-size.ts: -------------------------------------------------------------------------------- 1 | import { Theme, useMediaQuery } from "@material-ui/core"; 2 | 3 | export const useXS = () => useMediaQuery((theme) => theme.breakpoints.down("xs")); 4 | export const useSM = () => useMediaQuery((theme) => theme.breakpoints.down("sm")); 5 | export const useMD = () => useMediaQuery((theme) => theme.breakpoints.down("md")); 6 | export const useLG = () => useMediaQuery((theme) => theme.breakpoints.down("lg")); 7 | export const usePortrait = () => useMediaQuery("(orientation: portrait)"); 8 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | 15 | html { 16 | background-color: #303030; 17 | } 18 | 19 | @media only screen and (min-width: 600px) { 20 | html, body { 21 | overflow: hidden; 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import "./index.css"; 4 | import App from "./App"; 5 | import reportWebVitals from "./reportWebVitals"; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById("root") 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/md/home_en.md: -------------------------------------------------------------------------------- 1 | # 🐨 Welcome to the ScreamDB! 2 | 3 | ___ 4 | 5 | ## 🙋🏻‍♀️ Questions and answers 6 | 7 | * ### What is ScreamDB? 8 | ScreamDB is a web application for viewing DLC IDs of all games from the Epic Games store. 9 | 10 | * ### What is the use for DLC IDs? 11 | DLC IDs can be used for enabling/disabling desired DLCs in the **[ScreamAPI]** config file. 12 | 13 | * ### Where is the data coming from? 14 | The data comes directly from the official Epic Games servers, so it is always up-to-date. 15 | 16 | ___ 17 | 18 | ## 👩🏻‍💻 Extra info for developers 19 | 20 | * The web app is open-source and available at [GitHub]. 21 | 22 | [ScreamAPI]: https://cs.rin.ru/forum/viewtopic.php?f=29&t=106474 23 | 24 | [GraphQL endpoint]: https://www.epicgames.com/graphql 25 | 26 | [GitHub]: https://github.com/acidicoala/ScreamDB 27 | -------------------------------------------------------------------------------- /src/md/home_es.md: -------------------------------------------------------------------------------- 1 | # 🐨 ¡Bienvenido a ScreamDB! 2 | 3 | 4 | 5 | ___ 6 | 7 | 8 | 9 | ## 🙋🏻‍♀️ Preguntas y respuestas 10 | 11 | 12 | 13 | * ### ¿Qué es ScreamDB? 14 | 15 | ScreamDB es una aplicación web para ver las IDs de los DLCs de todos los juegos de la tienda de Epic Games. 16 | 17 | * ### ¿Para qué sirven las IDs de los DLCs? 18 | 19 | Las IDs de DLCs pueden ser usadas para activar/desactivar ciertos DLCs en el archivo de configuración de **[ScreamAPI]**. 20 | 21 | * ### ¿De dónde proviene esta información? 22 | 23 | La información proviene directamente de los servidores oficiales de Epic Games, así que siempre está actualizada. 24 | ___ 25 | 26 | ## 👩🏻‍💻 Información adicional para desarrolladores 27 | 28 | * La aplicación web es de código abierto y está disponible en [GitHub]. 29 | 30 | [ScreamAPI]: https://cs.rin.ru/forum/viewtopic.php?f=29&t=106474 31 | 32 | [GraphQL endpoint]: https://www.epicgames.com/graphql 33 | 34 | [GitHub]: https://github.com/acidicoala/ScreamDB 35 | -------------------------------------------------------------------------------- /src/md/home_ru.md: -------------------------------------------------------------------------------- 1 | # 🐨 Добро пожаловать на ScreamDB! 2 | 3 | ___ 4 | 5 | ## 🙋🏻‍♀️ Вопросы и ответы 6 | 7 | * ### Что такое ScreamDB? 8 | ScreamDB это веб приложение для просмотра идентификаторов (ID) дополнительного контента (DLC) для игр доступных в магазине Epic Games. 9 | 10 | * ### Зачем нужны DLC ID? 11 | DLC ID могут быть использованы для включения/отключения желаемых DLC в конфигурационном файле **[ScreamAPI]**. 12 | 13 | * ### Откуда берутся данные? 14 | Данные берутся напрямую с официальнов сереверов Epic Games, что делает их всегда актуальными. 15 | 16 | ___ 17 | 18 | ## 👩🏻‍💻 Дополнительная информация для разработчиков 19 | 20 | * Это веб приложение с открытым исходным кодом доступно на [GitHub]. 21 | 22 | [ScreamAPI]: https://cs.rin.ru/forum/viewtopic.php?f=29&t=106474 23 | 24 | [конечной точки GraphQL]: https://www.epicgames.com/graphql 25 | 26 | [GitHub]: https://github.com/acidicoala/ScreamDB 27 | -------------------------------------------------------------------------------- /src/md/home_zh.md: -------------------------------------------------------------------------------- 1 | # 🐨 欢迎来到 ScreamDB! 2 | 3 | ___ 4 | 5 | ## 🙋🏻‍♀️ 常见问题 6 | 7 | * ### ScreamDB 是什么? 8 | ScreamDB是一个用于查找Epic商城中的游戏DLC ID的网站。 9 | 10 | * ### DLC ID 能做什么? 11 | DLC ID可以用于在 **[ScreamAPI]** 的配置文件中启用或禁用DLC。 12 | 13 | * ### 数据来源是? 14 | 这些数据直接来自Epic官方服务器,所以永远是最新的。 15 | 16 | ___ 17 | 18 | ## 👩🏻‍💻 给开发者的额外信息 19 | 20 | * 本网站在 [GitHub] 上开源。 21 | 22 | [ScreamAPI]: https://cs.rin.ru/forum/viewtopic.php?f=29&t=106474 23 | 24 | [GraphQL endpoint]: https://www.epicgames.com/graphql 25 | 26 | [GitHub]: https://github.com/acidicoala/ScreamDB 27 | -------------------------------------------------------------------------------- /src/pages/Games.tsx: -------------------------------------------------------------------------------- 1 | import { Box, createStyles, makeStyles, Typography, useTheme } from "@material-ui/core"; 2 | import React, { useEffect, useState } from "react"; 3 | import { GameCardData, ValidSortDirection } from "../util/types"; 4 | import { useKeywords } from "../context/keywords"; 5 | import { sdk } from "../util/query"; 6 | import { SadFace } from "../components/util/SadFace"; 7 | import { useLocale } from "../hooks/locale"; 8 | import { Log } from "../util/log"; 9 | import { PaginatedContainer, usePaginationControls } from "../components/util/PaginatedContainer"; 10 | import { GameCard } from "../components/view-items/GameCard"; 11 | import { GameCardSkeleton } from "../components/skeletons/GameCardSkeleton"; 12 | import { KeyImageType } from "../generated/graphql"; 13 | import { SortBySelect, SortOption } from "../components/games/SortBySelect"; 14 | import { Sort } from "@material-ui/icons"; 15 | import { SortDirButton } from "../components/games/SortDirButton"; 16 | import { readProp } from "../util/storage"; 17 | import { ResponsiveBox } from "../components/util/ResponsiveBox"; 18 | import { Skeleton } from "@material-ui/lab"; 19 | 20 | const useStyles = makeStyles(({ breakpoints }) => 21 | createStyles({ 22 | grid: { 23 | display: "flex", 24 | margin: "auto", 25 | flexWrap: "wrap", 26 | justifyContent: "center", 27 | width: "100%", 28 | maxWidth: "100%", 29 | [breakpoints.up("sm")]: { 30 | maxWidth: breakpoints.values.sm, 31 | }, 32 | [breakpoints.up("md")]: { 33 | maxWidth: breakpoints.values.md, 34 | }, 35 | [breakpoints.up("lg")]: { 36 | maxWidth: breakpoints.values.lg, 37 | }, 38 | }, 39 | }) 40 | ); 41 | 42 | export function Games() { 43 | const { spacing } = useTheme(); 44 | const classes = useStyles(); 45 | const { locale } = useLocale(); 46 | const { keywords } = useKeywords(); 47 | const [games, setGames] = useState(); 48 | const pagination = usePaginationControls(games); 49 | 50 | const [sortBy, setSortBy] = useState( 51 | readProp("sort_games_by", SortOption.RELEVANCE) as SortOption 52 | ); 53 | const [sortDir, setSortDir] = useState( 54 | readProp("sort_games_dir", "ASC") as ValidSortDirection 55 | ); 56 | 57 | useEffect(() => { 58 | setGames(undefined); 59 | pagination.setPage(0); 60 | 61 | sdk 62 | .searchGames({ keywords: keywords, sortBy: sortBy, sortDir: sortDir }) 63 | .then((it) => it.Catalog!.searchStore.elements) 64 | .then((elements) => { 65 | setGames( 66 | elements.map((element) => ({ 67 | id: element.id, 68 | title: element.title, 69 | namespace: element.namespace, 70 | image: element.keyImages?.find((image) => image.type === KeyImageType.OfferImageTall) 71 | ?.url, 72 | creationDate: new Date(element.creationDate), 73 | })) 74 | ); 75 | }) 76 | .catch((reason) => { 77 | Log.error(reason); 78 | setGames([]); 79 | }); 80 | }, [keywords, sortBy, sortDir]); 81 | 82 | return ( 83 | 84 | {" "} 85 | {games?.length === 0 ? ( 86 | 87 | ) : ( 88 | 89 | 90 | {games ? ( 91 | 92 | ) : ( 93 | 94 | )} 95 | 96 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | {pagination 118 | .pageItems() 119 | ?.map((it) => ) ?? 120 | [...Array(pagination.itemsPerPage).keys()].map((it) => ( 121 | 122 | ))} 123 | 124 | 125 | )} 126 | 127 | ); 128 | } 129 | -------------------------------------------------------------------------------- /src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import { Box, createStyles, makeStyles } from "@material-ui/core"; 2 | 3 | import ReactMarkdown from "react-markdown"; 4 | import React, { useEffect, useState } from "react"; 5 | import home_en from "../md/home_en.md"; 6 | import home_es from "../md/home_es.md"; 7 | import home_ru from "../md/home_ru.md"; 8 | import home_zh from "../md/home_zh.md"; 9 | import { useLanguage } from "../context/language"; 10 | 11 | const useStyles = makeStyles(() => 12 | createStyles({ 13 | home: { 14 | "& a": { 15 | color: "#0E0", 16 | textDecoration: "none", 17 | "&:hover": { 18 | textDecoration: "underline", 19 | }, 20 | }, 21 | }, 22 | }) 23 | ); 24 | 25 | export function Home() { 26 | const { lang } = useLanguage(); 27 | const classes = useStyles(); 28 | 29 | const [home, setHome] = useState({ 30 | en: "", 31 | es: "", 32 | ru: "", 33 | zh: "", 34 | }); 35 | 36 | useEffect(() => { 37 | const en = fetch(home_en).then((file) => file.text()); 38 | const es = fetch(home_es).then((file) => file.text()); 39 | const ru = fetch(home_ru).then((file) => file.text()); 40 | const zh = fetch(home_zh).then((file) => file.text()); 41 | 42 | Promise.all([en, es, ru, zh]).then(([en, es, ru, zh]) => 43 | setHome({ 44 | en: en, 45 | es: es, 46 | ru: ru, 47 | zh: zh, 48 | }) 49 | ); 50 | }, []); 51 | 52 | return ( 53 | 54 | 55 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/pages/Offers.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { useParams } from "react-router-dom"; 3 | import { Box, IconButton, Tooltip, Typography } from "@material-ui/core"; 4 | import { Skeleton } from "@material-ui/lab"; 5 | import { Launch } from "@material-ui/icons"; 6 | import { SadFace } from "../components/util/SadFace"; 7 | import { OfferRowData } from "../util/types"; 8 | import { useLocale } from "../hooks/locale"; 9 | import { sdk } from "../util/query"; 10 | import { Log } from "../util/log"; 11 | import { TableView } from "../components/views/TableView"; 12 | import { PaginatedContainer, usePaginationControls } from "../components/util/PaginatedContainer"; 13 | import { readProp, writeProp } from "../util/storage"; 14 | import { Element, OfferType } from "../generated/graphql"; 15 | 16 | export function Offers() { 17 | const { locale } = useLocale(); 18 | const { namespace } = useParams<{ namespace?: string }>(); 19 | 20 | const [filterID, setFilterID] = useState(""); 21 | const [offers, setOffers] = useState(); 22 | const [gameInfo, setGameInfo] = useState>(); 23 | 24 | let initialOfferTypesFilters: Record = { 25 | ADD_ON: true, 26 | BASE_GAME: true, 27 | BUNDLE: true, 28 | EDITION: true, 29 | DLC: true, 30 | OTHERS: true, 31 | UNLOCKABLE: true, 32 | }; 33 | 34 | try { 35 | const storedOfferTypeFilters = JSON.parse(readProp("type_filters", "{}")); 36 | initialOfferTypesFilters = { 37 | ...initialOfferTypesFilters, 38 | ...storedOfferTypeFilters, 39 | }; 40 | } catch (e) { 41 | Log.error(e); 42 | } 43 | 44 | const [offerTypeFilters, setOfferTypeFilters] = 45 | useState>(initialOfferTypesFilters); 46 | 47 | const filteredOffers = offers 48 | ?.filter((it) => !filterID || it?.items?.some((item) => item.id.includes(filterID))) 49 | ?.filter((it) => offerTypeFilters[it.offerType] === true) 50 | ?.sort((first, second) => first.items.length - second.items.length); 51 | 52 | const pagination = usePaginationControls(filteredOffers); 53 | 54 | useEffect(() => { 55 | setOffers(undefined); 56 | 57 | if (!namespace) { 58 | setOffers([]); 59 | return; 60 | } 61 | 62 | sdk 63 | .searchOffers({ namespace: namespace }) 64 | .then((it) => it.Catalog!) 65 | .then((catalog) => { 66 | const gameInfo = catalog.searchStore.elements[0]; 67 | const elements = catalog.catalogOffers.elements; 68 | 69 | setGameInfo(gameInfo); 70 | setOffers( 71 | elements.map((element) => { 72 | const findImage = (type: string) => 73 | element.keyImages?.find((image) => image.type === type)?.url; 74 | 75 | return { 76 | id: element.id, 77 | title: element.title, 78 | offerType: element.offerType, 79 | items: element.items.map((item) => ({ 80 | title: elements.find((it) => it.items.length === 1 && it.items[0].id === item.id) 81 | ?.title, 82 | id: item.id, 83 | })), 84 | // Images are not guaranteed to be present, so try to pick the most reasonable 85 | image: 86 | findImage("OfferImageWide") ?? 87 | findImage("DieselStoreFrontWide") ?? 88 | findImage("Thumbnail") ?? 89 | findImage("CodeRedemption_340x440") ?? 90 | findImage("DieselStoreFrontTall") ?? 91 | findImage("OfferImageTall") ?? 92 | undefined, 93 | } as OfferRowData; 94 | }) 95 | ); 96 | }) 97 | .catch((reason) => { 98 | Log.error(reason); 99 | setOffers([]); 100 | }); 101 | }, [namespace]); 102 | 103 | return ( 104 | 105 | {offers?.length === 0 ? ( 106 | 107 | ) : ( 108 | 109 | 110 | 111 | {gameInfo ? ( 112 | <> 113 | 118 | 119 | 127 | 131 | } 132 | > 133 | } /> 134 | 135 | 136 | 137 | ) : ( 138 | 139 | )} 140 | 141 | 142 | 143 | { 148 | setOfferTypeFilters(filters); 149 | writeProp("type_filters", JSON.stringify(filters)); 150 | }} 151 | /> 152 | 153 | )} 154 | 155 | ); 156 | } 157 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from "web-vitals"; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /src/util/locale.ts: -------------------------------------------------------------------------------- 1 | export const rawLocale = { 2 | ascending: { 3 | en: "Ascending", 4 | ru: "По возрастающей", 5 | es: "Ascendente", 6 | zh: "升序", 7 | }, 8 | browse: { 9 | en: "Browse", 10 | ru: "Обзор", 11 | es: "Navegar", 12 | zh: "浏览", 13 | }, 14 | code_desc: { 15 | en: "You can directly copy and paste this code snippet at the bottom of the ScreamAPI config file", 16 | ru: "Вы можете напрямую скопировать и вставить данный фрагмент кода в конец конфигуграционного файла ScreamAPI", 17 | es: "Puedes copiar y pegar este retazo de código directamente al fondo del archivo de configuración de ScreamAPI", 18 | zh: "您可以直接将以下代码复制并粘贴至 ScreamAPI 配置文件底部", 19 | }, 20 | copy: { 21 | en: "Copy", 22 | ru: "Скопировать", 23 | es: "Copiar", 24 | zh: "复制", 25 | }, 26 | browse_games: { 27 | en: "Browse Games", 28 | ru: "Обзор Игр", 29 | es: "Navega juegos", 30 | zh: "浏览游戏", 31 | }, 32 | clear_all: { 33 | en: "Clear all", 34 | ru: "Убрать все", 35 | es: "Limpiar todo", 36 | zh: "清空", 37 | }, 38 | descending: { 39 | en: "Descending", 40 | ru: "По убывающей", 41 | es: "Descendiente", 42 | zh: "降序", 43 | }, 44 | filter_by_offer_type: { 45 | en: "Filter by offer type", 46 | ru: "Фильтр по типу контента", 47 | es: "Filtrar por tipo de oferta", 48 | zh: "按类型过滤", 49 | }, 50 | found_games: { 51 | en: "Found games", 52 | ru: "Найдено игр", 53 | es: "Juegos encontrados", 54 | zh: "已找到游戏", 55 | }, 56 | home: { 57 | en: "Home", 58 | ru: "Домой", 59 | es: "Hogar", 60 | zh: "主页", 61 | }, 62 | id: { 63 | en: "ID", 64 | ru: "ID", 65 | es: "ID", 66 | zh: "ID", 67 | }, 68 | image: { 69 | en: "Image", 70 | ru: "Изображение", 71 | es: "Imagen", 72 | zh: "图像", 73 | }, 74 | lang: { 75 | en: "English", 76 | ru: "Русский", 77 | es: "Español", 78 | zh: "中文(简体)", 79 | }, 80 | multiple_items: { 81 | en: "Multiple items", 82 | ru: "Несколько предметов", 83 | es: "Múltiples elementos", 84 | zh: "多项", 85 | }, 86 | no_offers: { 87 | en: "No offers found", 88 | ru: "Не найдено дополнительного контента", 89 | es: "No se han encontrado ofertas", 90 | zh: "未找到附加类容", 91 | }, 92 | no_games: { 93 | en: "No games found", 94 | ru: "Не найдено игр", 95 | es: "No se han encontrado juegos", 96 | zh: "未找到游戏", 97 | }, 98 | not_found: { 99 | en: "Page not found", 100 | ru: "Страница не найдена", 101 | es: "Página no encontrada", 102 | zh: "未找到页面", 103 | }, 104 | offer_type: { 105 | en: "Offer type", 106 | ru: "Тип контента", 107 | es: "Tipo de oferta", 108 | zh: "类型", 109 | }, 110 | search_by_id: { 111 | en: "Search by ID", 112 | ru: "Поиск по ID", 113 | es: "Buscar por ID", 114 | zh: "按 ID 搜索", 115 | }, 116 | search_games: { 117 | en: "Search for games", 118 | ru: "Поиск игр", 119 | es: "Busca juegos", 120 | zh: "搜索游戏", 121 | }, 122 | select_all: { 123 | en: "Select all", 124 | ru: "Выбрать все", 125 | es: "Seleccionar todo", 126 | zh: "全选", 127 | }, 128 | settings: { 129 | en: "Settings", 130 | ru: "Настройки", 131 | es: "Configuración", 132 | zh: "设置", 133 | }, 134 | showing_offers: { 135 | en: "Offers for ", 136 | ru: "Контент для ", 137 | es: "Contenido para ", 138 | zh: "结果自 ", 139 | }, 140 | sort_by: { 141 | en: "Sort by", 142 | ru: "Сортировать по", 143 | es: "Ordenar por", 144 | zh: "排序方式", 145 | }, 146 | sort_creation_date: { 147 | en: "Creation date", 148 | ru: "Дате создания", 149 | es: "Fecha de creación", 150 | zh: "公开日期", 151 | }, 152 | sort_current_price: { 153 | en: "Current price", 154 | ru: "Цене", 155 | es: "Precio", 156 | zh: "当前价格", 157 | }, 158 | sort_new_release: { 159 | en: "New release", 160 | ru: "Новинке", 161 | es: "Nuevo lanzamiento", 162 | zh: "最新推出", 163 | }, 164 | sort_release_date: { 165 | en: "Release date", 166 | ru: "Дате выхода", 167 | es: "Fecha de lanzamiento", 168 | zh: "推出日期", 169 | }, 170 | sort_relevance: { 171 | en: "Relevance", 172 | ru: "Актуальности", 173 | es: "Relevancia", 174 | zh: "相关性", 175 | }, 176 | sort_title: { 177 | en: "Title", 178 | ru: "Названию", 179 | es: "Título", 180 | zh: "标题", 181 | }, 182 | title: { 183 | en: "Title", 184 | ru: "Название", 185 | es: "Título", 186 | zh: "标题", 187 | }, 188 | version: { 189 | en: "Version", 190 | ru: "Версия", 191 | es: "Versión", 192 | zh: "版本", 193 | }, 194 | view_on_epic_store: { 195 | en: "Open in Epic Games Store", 196 | ru: "Открыть в магазине Epic Games ", 197 | es: "Abrir en la tienda de Epic Games", 198 | zh: "在Epic游戏商城中查看", 199 | }, 200 | }; 201 | -------------------------------------------------------------------------------- /src/util/log.ts: -------------------------------------------------------------------------------- 1 | export class Log { 2 | static readonly DEV = process.env.NODE_ENV === "development"; 3 | 4 | public static info(message: any) { 5 | if (Log.DEV) console.log(message); 6 | } 7 | 8 | public static error(message: any) { 9 | if (Log.DEV) console.error(message); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/util/paths.ts: -------------------------------------------------------------------------------- 1 | export const path = { 2 | to: { 3 | home: "/", 4 | games: "/games", 5 | offers: (namespace?: any) => `/offers/${namespace || ""}`, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /src/util/query.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLClient } from "graphql-request"; 2 | import { getSdk } from "../generated/graphql"; 3 | 4 | // Try to read the cors proxy from .env file first. 5 | // If it fails, use the raw graphql endpoint 6 | const ENDPOINT = 7 | process.env.REACT_APP_CORS_PROXY ?? "https://www.epicgames.com/graphql"; 8 | 9 | const client = new GraphQLClient(ENDPOINT); 10 | export const sdk = getSdk(client); 11 | -------------------------------------------------------------------------------- /src/util/storage.ts: -------------------------------------------------------------------------------- 1 | export function writeProp(key: LocalStorageProps, value: any) { 2 | localStorage.setItem(key, value); 3 | } 4 | 5 | export function readProp(key: LocalStorageProps, defaultValue: string) { 6 | let property = localStorage.getItem(key); 7 | if (!property) { 8 | property = defaultValue; 9 | writeProp(key, defaultValue); 10 | } 11 | return property; 12 | } 13 | 14 | export const maxWidth = "lg"; 15 | 16 | export type LocalStorageProps = 17 | | "item_per_page" 18 | | "lang" 19 | | "sort_games_by" 20 | | "sort_games_dir" 21 | | "type_filters"; 22 | -------------------------------------------------------------------------------- /src/util/types.ts: -------------------------------------------------------------------------------- 1 | import { Item, OfferType } from "../generated/graphql"; 2 | 3 | export interface GameCardData { 4 | id: string; 5 | title: string; 6 | namespace: string; 7 | image?: string | null; 8 | creationDate: Date; 9 | } 10 | 11 | export interface OfferRowData { 12 | id: string; 13 | title: string; 14 | offerType: OfferType; 15 | items: Pick[]; 16 | image?: string; 17 | } 18 | 19 | export type ValidLanguage = "en" | "es" | "ru" | "zh"; 20 | export type ValidSortDirection = "ASC" | "DESC"; 21 | -------------------------------------------------------------------------------- /src/util/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.md" { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ], 26 | "exclude": [ 27 | "workers-site" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /workers/epic-cors-proxy.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-restricted-globals 2 | addEventListener("fetch", event => { 3 | event.respondWith(handleRequest(event.request)); 4 | }); 5 | 6 | /** 7 | * Respond to the request 8 | * @param {Request} request 9 | */ 10 | async function handleRequest(request) { 11 | const patchedRequest = new Request("https://www.epicgames.com/graphql", { 12 | ...request, 13 | headers: new Headers({ 14 | // Define customer headers to avoid faulty headers like "Referer" 15 | "Content-type": "application/json" 16 | }) 17 | }); 18 | 19 | return fetch(patchedRequest.url, patchedRequest).then(res => { 20 | const origin = request.headers.get("origin"); 21 | const headers = new Headers(res.headers); 22 | headers.set("Access-Control-Allow-Origin", origin); 23 | headers.set("Access-Control-Allow-Headers", "Content-Type"); 24 | 25 | return new Response(res.body, { 26 | ...res, 27 | headers: headers 28 | }); 29 | }); 30 | } --------------------------------------------------------------------------------