moved frontend from monorepo

This commit is contained in:
John O'Keefe 2024-09-12 15:55:38 -04:00
parent 0e84e37a74
commit 3af14dee95
91 changed files with 5989 additions and 0 deletions

BIN
bun.lockb Executable file

Binary file not shown.

9
config-overrides.js Normal file
View File

@ -0,0 +1,9 @@
module.exports = function override(webpackConfig) {
webpackConfig.module.rules.push({
test: /\.mjs$/,
include: /node_modules/,
type: 'javascript/auto',
})
return webpackConfig
}

47
index.html Normal file
View File

@ -0,0 +1,47 @@
<!DOCTYPE html>
<!--suppress ALL -->
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="/manifest.json" />
<!--
Notice the use of in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", /favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Games Database</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

3
notes.txt Normal file
View File

@ -0,0 +1,3 @@
Include somewhere with image on error page. Probably in video alt.
Image by <a href="https://pixabay.com/users/alexantropov86-2691829/?utm_source=link-attribution&amp;utm_medium=referral&amp;utm_campaign=image&amp;utm_content=2213140">Alexander Antropov</a> from <a href="https://pixabay.com/?utm_source=link-attribution&amp;utm_medium=referral&amp;utm_campaign=image&amp;utm_content=2213140">Pixabay</a>

76
package.json Normal file
View File

@ -0,0 +1,76 @@
{
"name": "games-database-client",
"version": "0.1.0",
"private": true,
"dependencies": {
"@chakra-ui/icons": "^2.1.1",
"@chakra-ui/react": "^2.8.2",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@hookstate/core": "^4.0.1",
"@hookstate/localstored": "^4.0.2",
"axios": "^1.5.0",
"bson-objectid": "^2.0.4",
"chakra-react-select": "^4.7.2",
"chakra-ui-contextmenu": "^1.0.5",
"framer-motion": "^10.16.4",
"interweave": "^13.1.0",
"jodit-react": "^1.3.39",
"js-base64": "^3.7.5",
"react": "^18.2.0",
"react-device-detect": "^2.2.3",
"react-dom": "^18.2.0",
"react-hook-form": "^7.48.2",
"react-icons": "^4.11.0",
"react-rating": "^2.0.5",
"react-responsive-carousel": "^3.2.23",
"react-router-dom": "^5.3.4",
"react-select": "^5.7.4",
"react-toastify": "^9.1.3",
"web-vitals": "^3.4.0"
},
"scripts": {
"start": "bunx --bun vite",
"buildProject": "bunx --bun vite build",
"serve": "vite preview",
"serveprod": "serve ./dist/index.html"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"resolutions": {
"@types/react": "18.2.21",
"@types/react-dom": "18.2.7"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@chakra-ui/cli": "^2.4.1",
"@hookstate/devtools": "^4.0.1",
"@testing-library/jest-dom": "^6.1.3",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.4.3",
"@types/jest": "^29.5.4",
"@types/node": "^20.6.0",
"@types/react": "^18.2.21",
"@types/react-dom": "^18.2.7",
"@types/react-router-dom": "^5.3.3",
"@types/react-table": "^7.7.15",
"@vitejs/plugin-react": "^4.0.4",
"typescript": "^5.2.2",
"vite": "^4.4.9"
}
}

51
public/assets/Logo.svg Normal file
View File

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 998 185" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-431,-466)">
<g id="Logo">
<g transform="matrix(0.239118,0,0,0.239118,507.822,474.94)">
<g transform="matrix(1,0,0,1,-99.5515,0)">
<path d="M238.034,381.504L308.822,381.504L308.822,431L204.975,431L155.479,381.504L155.479,232.644L204.975,183.149L238.034,183.149L238.034,381.504ZM281.74,293.533L395.112,293.533L395.112,431L321.896,431L321.896,342.655L281.74,342.655L281.74,293.533ZM251.108,232.644L251.108,183.149L351.781,183.149L395.112,232.644L395.112,257.485L312.558,257.485L312.558,232.644L251.108,232.644Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<path d="M563.397,431L563.397,282.14L445.915,282.14L445.915,257.485L470.757,232.644L594.589,232.644L644.085,282.14L644.085,431L563.397,431ZM550.323,315.573L550.323,349.939L507.738,349.939L507.738,384.68L550.323,384.68L550.323,406.532L525.669,431L462.539,431L429.292,398.314L429.292,348.632L462.539,315.573L550.323,315.573Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M684.055,431L684.055,232.644L766.796,232.644L766.796,431L684.055,431ZM813.49,282.14L779.87,282.14L779.87,258.793L805.832,232.644L869.71,232.644L896.232,259.166L896.232,431L813.49,431L813.49,282.14ZM942.926,282.14L909.306,282.14L909.306,259.166L934.708,232.644L985.137,232.644L1025.48,273.361L1025.48,431L942.926,431L942.926,282.14Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M1143.71,431L1110.65,431L1061.15,381.504L1061.15,282.14L1110.65,232.644L1143.71,232.644L1143.71,431ZM1156.78,351.434L1156.78,316.88L1200.3,316.88L1200.3,282.14L1156.78,282.14L1156.78,232.644L1226.83,232.644L1276.32,282.14L1276.32,351.434L1156.78,351.434ZM1156.78,431L1156.78,384.68L1267.73,384.68L1267.73,406.159L1243.07,431L1156.78,431Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M1354.58,232.644L1387.64,232.644L1387.64,302.685L1481.77,302.685L1524.73,346.017L1524.73,384.68L1478.6,431L1442.18,431L1442.18,356.103L1352.9,356.103L1309.94,314.825L1309.94,277.097L1354.58,232.644ZM1392.5,394.579L1429.1,394.579L1429.1,431L1344.12,431L1309.75,390.096L1309.75,374.22L1392.5,374.22L1392.5,394.579ZM1442.18,269.065L1400.71,269.065L1400.71,232.644L1488.5,232.644L1518.01,262.155L1518.01,284.568L1442.18,284.568L1442.18,269.065Z" style="fill:white;fill-rule:nonzero;"/>
<g transform="matrix(1,0,0,1,292.742,0)">
<path d="M1754.09,431L1754.09,381.504L1815.36,381.504L1815.36,232.644L1754.09,232.644L1754.09,183.149L1848.41,183.149L1898.1,232.644L1898.1,381.504L1848.41,431L1754.09,431ZM1658.46,183.149L1741.02,183.149L1741.02,431L1658.46,431L1658.46,183.149Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,292.742,0)">
<path d="M2064.33,431L2064.33,282.14L1946.85,282.14L1946.85,257.485L1971.69,232.644L2095.52,232.644L2145.01,282.14L2145.01,431L2064.33,431ZM2051.25,315.573L2051.25,349.939L2008.67,349.939L2008.67,384.68L2051.25,384.68L2051.25,406.532L2026.6,431L1963.47,431L1930.22,398.314L1930.22,348.632L1963.47,315.573L2051.25,315.573Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,292.742,0)">
<path d="M2266.98,381.504L2321.52,381.504L2321.52,431L2234.11,431L2184.42,381.504L2184.42,282.14L2167.99,282.14L2167.99,232.644L2184.42,232.644L2184.42,211.165L2266.98,183.149L2266.98,381.504ZM2280.05,282.14L2280.05,232.644L2324.51,232.644L2324.51,282.14L2280.05,282.14Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,292.742,0)">
<path d="M2483.83,431L2483.83,282.14L2366.34,282.14L2366.34,257.485L2391.18,232.644L2515.02,232.644L2564.51,282.14L2564.51,431L2483.83,431ZM2470.75,315.573L2470.75,349.939L2428.17,349.939L2428.17,384.68L2470.75,384.68L2470.75,406.532L2446.1,431L2382.97,431L2349.72,398.314L2349.72,348.632L2382.97,315.573L2470.75,315.573Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,292.742,0)">
<path d="M2684.24,157L2684.24,431L2601.68,431L2601.68,157L2684.24,157ZM2734.29,282.14L2697.31,282.14L2697.31,257.112L2721.59,232.644L2766.79,232.644L2816.47,282.14L2816.47,381.504L2766.79,431L2697.31,431L2697.31,384.68L2734.29,384.68L2734.29,282.14Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,292.742,0)">
<path d="M2982.7,431L2982.7,282.14L2865.22,282.14L2865.22,257.485L2890.06,232.644L3013.89,232.644L3063.39,282.14L3063.39,431L2982.7,431ZM2969.63,315.573L2969.63,349.939L2927.04,349.939L2927.04,384.68L2969.63,384.68L2969.63,406.532L2944.97,431L2881.85,431L2848.6,398.314L2848.6,348.632L2881.85,315.573L2969.63,315.573Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,292.742,0)">
<path d="M3143.7,232.644L3176.76,232.644L3176.76,302.685L3270.9,302.685L3313.86,346.017L3313.86,384.68L3267.72,431L3231.3,431L3231.3,356.103L3142.02,356.103L3099.07,314.825L3099.07,277.097L3143.7,232.644ZM3181.62,394.579L3218.23,394.579L3218.23,431L3133.24,431L3098.88,390.096L3098.88,374.22L3181.62,374.22L3181.62,394.579ZM3231.3,269.065L3189.84,269.065L3189.84,232.644L3277.62,232.644L3307.13,262.155L3307.13,284.568L3231.3,284.568L3231.3,269.065Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,292.742,0)">
<path d="M3427.04,431L3393.98,431L3344.49,381.504L3344.49,282.14L3393.98,232.644L3427.04,232.644L3427.04,431ZM3440.12,351.434L3440.12,316.88L3483.64,316.88L3483.64,282.14L3440.12,282.14L3440.12,232.644L3510.16,232.644L3559.65,282.14L3559.65,351.434L3440.12,351.434ZM3440.12,431L3440.12,384.68L3551.06,384.68L3551.06,406.159L3526.41,431L3440.12,431Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</g>
<g id="gamepad.svg" transform="matrix(0.127966,0,0,0.127966,923.759,545.241)">
<g transform="matrix(1,0,0,1,-256,-256)">
<path d="M155.084,125.945C154.624,125.945 154.158,125.955 153.687,125.979C148.041,126.264 141.59,128.443 132.98,134.183C111.156,148.733 81.068,194.578 65.146,244.188C49.226,293.798 47.1,346.438 71.082,377.154C75.224,382.46 84.469,386.084 94.838,386C105.054,385.916 115.52,382.162 121.32,376.56C122.342,375.09 130.616,363.224 142.71,349.156C155.573,334.196 171.426,317.47 188.545,310.379C230.408,293.039 281.569,293.039 323.432,310.379C340.55,317.471 356.402,334.197 369.266,349.157C381.361,363.225 389.636,375.09 390.656,376.561C396.456,382.163 406.923,385.917 417.139,386.001C427.507,386.086 436.751,382.461 440.894,377.155C464.867,346.451 462.779,293.58 446.872,243.868C430.965,194.155 400.818,148.342 379.089,134.244C367.591,126.784 359.891,125.514 352.804,126.604C345.716,127.697 338.457,131.801 329.938,137.674C312.9,149.42 291.04,167.694 255.986,167.694C220.774,167.694 198.871,149.18 181.856,137.338C173.351,131.418 166.126,127.313 159.113,126.26C157.798,126.062 156.463,125.948 155.083,125.943L155.084,125.945ZM367.988,174.695C376.765,174.695 383.988,181.918 383.988,190.695C383.988,199.472 376.765,206.695 367.988,206.695C359.211,206.695 351.988,199.472 351.988,190.695C351.988,181.918 359.211,174.695 367.988,174.695ZM135,183L153,183L153,215L185,215L185,233L153,233L153,265L135,265L135,233L103,233L103,215L135,215L135,183ZM335.988,206.695C344.765,206.695 351.988,213.918 351.988,222.695C351.988,231.472 344.765,238.695 335.988,238.695C327.211,238.695 319.988,231.472 319.988,222.695C319.988,213.918 327.211,206.695 335.988,206.695ZM399.988,206.695C408.765,206.695 415.988,213.918 415.988,222.695C415.988,231.472 408.765,238.695 399.988,238.695C391.211,238.695 383.988,231.472 383.988,222.695C383.988,213.918 391.211,206.695 399.988,206.695ZM367.988,238.695C376.765,238.695 383.988,245.918 383.988,254.695C383.988,263.472 376.765,270.695 367.988,270.695C359.211,270.695 351.988,263.472 351.988,254.695C351.988,245.918 359.211,238.695 367.988,238.695ZM207.988,245.695L239.988,245.695L239.988,263.695L207.988,263.695L207.988,245.695ZM271.988,245.695L299.885,245.695L299.885,263.695L271.988,263.695L271.988,245.695Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</g>
<g id="pc.svg" transform="matrix(0.359737,0,0,0.359737,523.093,558.093)">
<g transform="matrix(1,0,0,1,-256,-256)">
<path d="M29.65,117.89L29.65,394.11L154.27,394.11L154.27,117.89L29.65,117.89ZM120.2,371.05C114.166,371.05 109.2,366.084 109.2,360.05C109.2,354.016 114.166,349.05 120.2,349.05C126.234,349.05 131.2,354.016 131.2,360.05C131.2,366.084 126.234,371.05 120.2,371.05ZM138.2,181.89L45.56,181.89L45.56,165.89L138.19,165.89L138.19,181.89L138.2,181.89ZM138.2,149.89L45.56,149.89L45.56,133.89L138.19,133.89L138.19,149.89L138.2,149.89ZM291.2,338.4L364.3,338.4L364.3,378.11L406.04,378.11L406.04,394.11L249.48,394.11L249.48,378.11L291.22,378.11L291.22,338.4L291.2,338.4ZM173.2,117.89L173.2,322.4L482.35,322.4L482.35,117.89L173.19,117.89L173.2,117.89ZM466.35,306.4L189.19,306.4L189.19,133.89L466.35,133.89L466.35,306.4Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.6 KiB

View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1080 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path id="Artboard1" d="M1080,216C1080,96.786 983.214,0 864,0L216,0C96.786,0 0,96.786 0,216L0,864C0,983.214 96.786,1080 216,1080L864,1080C983.214,1080 1080,983.214 1080,864L1080,216Z" style="fill:url(#_Radial1);"/>
<clipPath id="_clip2">
<path id="Artboard11" serif:id="Artboard1" d="M1080,216C1080,96.786 983.214,0 864,0L216,0C96.786,0 0,96.786 0,216L0,864C0,983.214 96.786,1080 216,1080L864,1080C983.214,1080 1080,983.214 1080,864L1080,216Z"/>
</clipPath>
<g clip-path="url(#_clip2)">
<g transform="matrix(0.690209,0.72361,-0.72361,0.690209,558.037,-223.462)">
<path d="M326.9,728.816C280.975,678.813 253,612.591 253,540C253,384.36 381.6,258 540,258C698.4,258 827,384.36 827,540C827,695.64 698.4,822 540,822C489.157,822 441.384,808.982 399.949,786.155L550.64,724.438L520.037,649.715L326.9,728.816Z" style="fill:url(#_Linear3);"/>
</g>
<path d="M541.381,95.002C786.349,95.748 985,294.858 985,540C985,785.142 786.349,984.252 541.381,984.998L541.381,885.702C731.547,884.957 885.705,730.339 885.705,540C885.705,349.661 731.547,195.043 541.381,194.298L541.381,95.002Z"/>
<g transform="matrix(0.737705,0,0,0.737705,104.705,102.639)">
<circle cx="708" cy="479" r="61" style="fill:rgb(31,62,107);"/>
</g>
<path d="M627,363C678.328,363 720,404.672 720,456C720,507.328 678.328,549 627,549C575.672,549 534,507.328 534,456C534,404.672 575.672,363 627,363ZM627,397.428C659.327,397.428 685.572,423.673 685.572,456C685.572,488.327 659.327,514.572 627,514.572C594.673,514.572 568.428,488.327 568.428,456C568.428,423.673 594.673,397.428 627,397.428Z" style="fill:rgb(31,62,107);"/>
<g transform="matrix(-0.486753,-0.597406,0.775249,-0.631656,372.779,1129.97)">
<path d="M534,564L376,564L415.5,406L494.5,406L534,564Z" style="fill:rgb(31,62,107);"/>
</g>
<g transform="matrix(1,0,0,1,19.8252,13)">
<path d="M406,553.837C444.127,553.837 475.081,584.792 475.081,622.919C475.081,661.046 444.127,692 406,692C367.873,692 336.919,661.046 336.919,622.919C336.919,584.792 367.873,553.837 406,553.837ZM406,571.108C434.595,571.108 457.811,594.323 457.811,622.919C457.811,651.514 434.595,674.73 406,674.73C377.405,674.73 354.189,651.514 354.189,622.919C354.189,594.323 377.405,571.108 406,571.108Z" style="fill:rgb(31,62,107);"/>
</g>
<g transform="matrix(1.31489,0.587666,-0.822016,1.83925,393.401,-726.359)">
<path d="M296.936,630.919C283.279,618.809 272.542,605.247 265.089,590.837L410,590.837L410,630.919L296.936,630.919Z" style="fill:rgb(31,62,107);"/>
</g>
<g transform="matrix(1,0,0,1,15.8252,5)">
<circle cx="410" cy="630.919" r="40.081" style="fill:rgb(31,62,107);"/>
</g>
</g>
<defs>
<radialGradient id="_Radial1" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(540,0,0,540,540,540)"><stop offset="0" style="stop-color:rgb(42,145,215);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(7,16,39);stop-opacity:1"/></radialGradient>
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(574,0,0,564,253,540)"><stop offset="0" style="stop-color:rgb(183,94,206);stop-opacity:1"/><stop offset="0.51" style="stop-color:rgb(47,148,242);stop-opacity:0.7"/><stop offset="1" style="stop-color:rgb(0,166,255);stop-opacity:0.6"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

15
public/manifest.json Normal file
View File

@ -0,0 +1,15 @@
{
"short_name": "Games Database",
"name": "Games Database",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
public/robots.txt Normal file
View File

@ -0,0 +1,3 @@
# https://www.js.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

3
src/@types/game.ts Normal file
View File

@ -0,0 +1,3 @@
export type RecursivePartial<T> = {
[P in keyof T]?: RecursivePartial<T[P]>;
};

9
src/App.test.tsx Normal file
View File

@ -0,0 +1,9 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

112
src/App.tsx Normal file
View File

@ -0,0 +1,112 @@
import React, { useEffect } from 'react'
import GameDashboard from './components/GamesDashboard'
import { Redirect, Route, Switch, useLocation } from 'react-router-dom'
import Header from './components/Header'
import Home from './components/Home'
import GameDetails from './components/GameDetails'
import GameForm from './components/GameForm'
import NotFound from './errors/NotFound'
import TestErrors from './errors/TestError'
import Footer from './components/Footer'
import LoginForm from './components/authComponents/LoginForm'
import {
setAppLoaded,
getUser,
appLoadedState,
loggedInState, loadedPreferences,
} from './stateManagement/userState'
import {useHookstate} from '@hookstate/core'
import { ToastContainer } from 'react-toastify'
import NoGames from './components/NoGames'
import CreateUser from './components/userComponents/CreateUser'
import LoadingModal from './components/LoadingModal'
import SomethingWentWrong from './errors/SomethingWentWrong'
import ForgotPassword from './components/authComponents/ForgotPassword'
import ResetPassword from './components/authComponents/ResetPassword'
import UserProfile from './components/userComponents/UserProfile'
import EditUser from './components/userComponents/EditUser'
import {Box, useColorMode} from "@chakra-ui/react";
function App() {
const location = useLocation<Location>()
const appLoaded = useHookstate(appLoadedState)
const appLoad = appLoaded.get()
const isLoggedIn = useHookstate(loggedInState)
const loggedIn = isLoggedIn.get()
const originalToken = window.localStorage.getItem('jwt')
const { colorMode, toggleColorMode } = useColorMode()
useEffect(() => {
if (originalToken) getUser(originalToken).finally(() => setAppLoaded(true))
else setAppLoaded(false)
}, [originalToken])
if (!appLoad) <LoadingModal />
useEffect(() => {
if(loadedPreferences.get().theme !== colorMode) {
localStorage.removeItem('chakra-ui-color-mode')
toggleColorMode()
}
}, [loadedPreferences.get().theme]);
// noinspection SpellCheckingInspection
return (
<>
<ToastContainer position='bottom-right' hideProgressBar />
{loggedIn ? <Header /> : ''}
<Route exact path='/'>
{loggedIn ? <Redirect to='/games' /> : <Home />}
</Route>
<Box mt={loadedPreferences.get().stickyNav ? "20" : "4"} mb={'4'}>
{/*<Box mt={loadedPreferences.get().stickyNav ? isSmallerThan768 ? "15%" : "9%" : "4"} mb={'4'}>*/}
<Switch>
<Route
key={location.key}
path={['/games/create', '/games/:id/edit']}
component={() => <GameForm />}
/>
<Route path='/games/:id'>
<GameDetails />
</Route>
<Route path='/games'>
<GameDashboard />
</Route>
<Route path='/nogames'>
<NoGames />
</Route>
<Route path='/login'>
<LoginForm />
</Route>
<Route path='/profile'>
<UserProfile />
</Route>
<Route path='/forgotpassword'>
<ForgotPassword />
</Route>
<Route path='/resetpassword/:token'>
<ResetPassword />
</Route>
<Route path='/create-user'>
<CreateUser />
</Route>
<Route path='/edit-user'>
<EditUser />
</Route>
<Route path='/errors'>
<TestErrors />
</Route>
<Route path='/something-went-wrong'>
<SomethingWentWrong />
</Route>
<Route>
<NotFound />
</Route>
</Switch>
</Box>
{loggedIn ? <Footer /> : ''}
</>
)
}
export default App

128
src/api/agent.ts Normal file
View File

@ -0,0 +1,128 @@
import axios, {AxiosError, AxiosResponse} from 'axios'
import Game, {Data, GameList} from '../models/game'
import {toast} from 'react-toastify'
import {history} from '..'
import User, {IForgotPassword, IResetPassword, UserAPI, UserFormValues, UserUpdateValues} from '../models/user'
import {userToken} from '../stateManagement/userState'
import {RecursivePartial} from "../@types/game";
const sleep = (delay: number) => {
return new Promise((resolve) => {
setTimeout(resolve, delay)
})
}
axios.defaults.baseURL = import.meta.env.VITE_API_URL
axios.interceptors.request.use((config) => {
const token = userToken.get()
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
axios.interceptors.response.use(
async (response) => {
await sleep(0)
return response
},
(error: AxiosError) => {
const {data, status, config} = error.response!
switch (status) {
case 400:
// @ts-ignore
if (typeof data.error === 'string') toast.error(data.error)
if (config.method === 'get') history.push('/not-found')
// @ts-ignore
if (data.errors) {
const modalStateErrors = []
// @ts-ignore
for (const key in data.errors) {
// @ts-ignore
if (data.errors[key]) modalStateErrors.push(data.errors[key])
}
throw modalStateErrors.flat()
}
break
case 401:
toast.error('Unauthorized')
history.push('/')
break
case 404:
history.push('/not-found')
break
case 429:
history.push('/something-went-wrong')
break
case 500:
toast.error('Server Error')
break
}
return Promise.reject(error)
}
)
const responseBody = <T>(response: AxiosResponse<T>) => response.data
interface Tags {
success: boolean,
data: {
genre: string[]
series: string[]
store: string[]
developer: string[]
publisher: string[]
}
}
const requests = {
get: <T>(url: string) => axios.get<T>(url).then(responseBody),
post: <T>(url: string, body: {}) =>
axios.post<T>(url, body).then(responseBody),
put: <T>(url: string, body: {}) => axios.put<T>(url, body).then(responseBody),
delete: <T>(url: string) => axios.delete<T>(url).then(responseBody),
}
const Games = {
list: (filter: string) => requests.get<GameList>(`/games?${filter}`),
details: (id: string) => requests.get<Game>(`/games/${id}`),
create: (game: Data) => axios.post<void>('/games', game),
update: (id: string, game: Data) => axios.put<void>(`/games/${id}`, game),
playStatus: (id: string, playStatus: RecursivePartial<Data>) => axios.put<void>(`/games/${id}`, playStatus),
rating: (id: string, rating: RecursivePartial<Data>) => axios.put<void>(`/games/${id}`, rating),
delete: (id: string) => axios.delete<void>(`/games/${id}`),
}
const GamesAdmin = {
list: (filter: string) => requests.get<GameList>(`/admin/games?${filter}`),
details: (id: string) => requests.get<Game>(`/admin/games/${id}`),
create: (game: Data) => axios.post<void>('/admin/games', game),
update: (id: string, game: Data) => axios.put<void>(`/admin/games/${id}`, game),
delete: (id: string) => axios.delete<void>(`/admin/games/${id}`),
}
const RetrieveTags = {
tags: () => requests.get<Tags>('/tags'),
genres: () => requests.get<string[]>('/tags/genres'),
series: () => requests.get<string[]>('/tags/series'),
store: () => requests.get<string[]>('/tags/store'),
developer: () => requests.get<string[]>('/tags/developer'),
publisher: () => requests.get<string[]>('/tags/publisher'),
}
const Account = {
current: () => requests.get<UserAPI>('/auth/me'),
login: (user: UserFormValues) => requests.post<User>('/auth/login', user),
register: (user: UserFormValues) => requests.post<User>('/auth/register', user),
forgot: (email: IForgotPassword) => requests.post('/auth/forgotpassword', email),
reset: (token: string, password: IResetPassword) => requests.put(`/auth/resetpassword/${token}`, password),
update: (user: UserUpdateValues) => requests.put('/auth/updatedetails', user)
}
const agent = {
Games,
GamesAdmin,
RetrieveTags,
Account,
}
export default agent

View File

@ -0,0 +1,40 @@
import stringArrayToSelectObject from './stringArrayToSelectObject'
export const controllerValues = stringArrayToSelectObject(['Full Controller Support', 'Partial Controller Support', 'No Controller Support'])
export const wineIntelValues = stringArrayToSelectObject(['Yes', 'No', 'Not Tested'])
export const playStatusValues = stringArrayToSelectObject(['Never Played', 'Up Next', 'Playing', 'Finished', 'Will Not Play'])
export const ratingValues = stringArrayToSelectObject(["0","0.5","1","1.5","2","2.5","3","3.5","4","4.5","5","5.5","6","6.5","7","7.5","8","8.5","9","9.5","10"])
export const ratingStateValues = [
{ label: '=', value: '[]' },
{ label: '<=', value: '[lte]' },
{ label: '>=', value: '[gte]' },
]
export const osValues = [
{
value: 'windows',
label: 'Windows'
},
{
value: 'mac',
label: 'Mac OSX'
},
{
value: 'linux',
label: 'Linux'
},
{
value: 'android',
label: 'Android'
},
{
value: 'ios',
label: 'iOS'
}
]
export const yesNo = stringArrayToSelectObject(['Yes', 'No'])

View File

@ -0,0 +1,25 @@
import {State} from "@hookstate/core";
import {Filters} from "../models/game";
export const ChangeActiveFilterHeader = (
filters: Filters,
searchParams: string,
setShowFiltersModuleState: State<boolean>
) => {
if (
searchParams.length > 0 ||
filters.series.length > 0 ||
filters.playStatus.length > 0 ||
filters.genre.length > 0 ||
filters.os.length > 0 ||
filters.store.length > 0 ||
filters.developer.length > 0 ||
filters.publisher.length > 0 ||
filters.controller.length > 0 ||
filters.soundtrack.length > 0 ||
filters.intel.length > 0 ||
filters.wine.length > 0 ||
filters.rating.length > 0
) setShowFiltersModuleState.set(true)
else setShowFiltersModuleState.set(false)
}

View File

@ -0,0 +1,39 @@
import {State} from '@hookstate/core'
import {Filters, GameList} from '../models/game'
import agent from '../api/agent'
const getWithFilters = async (
gamesState: State<GameList>,
loadedState: State<boolean>,
admin: boolean,
page: number,
limit: number,
search: string,
filters: Filters,
ratingState: string,
steamRatingState: string,
) => {
loadedState.set(false)
const filterString = 'page=' + page.toString() +
'&limit=' + limit.toString() +
'&search=' + encodeURIComponent(search) +
'&series=' + encodeURIComponent(filters.series) +
'&playStatus=' + encodeURIComponent(filters.playStatus) +
'&genre=' + encodeURIComponent(filters.genre) +
'&os=' + encodeURIComponent(filters.os) +
'&store=' + encodeURIComponent(filters.store) +
'&developer=' + encodeURIComponent(filters.developer) +
'&publisher=' + encodeURIComponent(filters.publisher) +
'&controller=' + encodeURIComponent(filters.controller) +
'&soundtrack=' + encodeURIComponent(filters.soundtrack) +
'&intel=' + encodeURIComponent(filters.intel) +
'&wine=' + encodeURIComponent(filters.wine) +
'&rating' + (filters.rating !== '' ? ratingState + '=' + encodeURIComponent(filters.rating.toString()) : '=') +
'&steamRating' + (filters.steamRating !== '' ? steamRatingState + '=' + encodeURIComponent(filters.steamRating.toString()) : '=')
const getGames = admin ? await agent.GamesAdmin.list(filterString) : await agent.Games.list(filterString)
gamesState.set(getGames)
loadedState.set(true)
}
export default getWithFilters

View File

@ -0,0 +1,18 @@
import agent from '../api/agent'
import { tagState } from '../stateManagement/tagState'
import stringArrayToSelectObject from './stringArrayToSelectObject'
export const retrieveTags = async () => {
const tagsAPI = await agent.RetrieveTags.tags()
const genres = stringArrayToSelectObject(tagsAPI.data.genre)
const series = stringArrayToSelectObject(tagsAPI.data.series)
const store = stringArrayToSelectObject(tagsAPI.data.store)
const developer = stringArrayToSelectObject(tagsAPI.data.developer)
const publisher = stringArrayToSelectObject(tagsAPI.data.publisher)
tagState.genres.merge(genres)
tagState.series.set(series)
tagState.store.set(store)
tagState.developer.set(developer)
tagState.publisher.set(publisher)
}

View File

@ -0,0 +1,8 @@
const stringArrayToSelectObject = (arr: string[]) => {
return arr.reduce((prev: { value: string, label: string }[], item) => {
prev.push({ value: item, label: item })
return prev
}, [])
}
export default stringArrayToSelectObject;

View File

@ -0,0 +1,132 @@
import React from 'react'
import { useHookstate } from '@hookstate/core'
import { gameDashboardState } from '../stateManagement/gameState'
import { Flex, Stack, HStack, Link, Spacer, Tag, TagCloseButton, TagLabel } from '@chakra-ui/react'
import getWithFilters from '../componentUtils/getWithFilters'
import {
loadingState,
openAndCloseSearchDialogue,
searchCompletedState,
showFiltersModuleState
} from '../stateManagement/loadingState'
import { adminMode } from '../stateManagement/userState'
import ResetFilterButton from './helperComponents/ResetFilterButton'
import { ChangeActiveFilterHeader } from "../componentUtils/changeActiveFilterHeader";
import { ratingState, steamRatingState } from "../stateManagement/ratingState";
const ActiveFilters = () => {
const setShowFiltersModuleState = useHookstate(showFiltersModuleState)
const showFiltersModule = setShowFiltersModuleState.get()
const gamesState = useHookstate(gameDashboardState)
const games = gamesState.get()
const { filters, searchParams } = games
const loadedState = useHookstate(loadingState)
const admin = useHookstate(adminMode).get()
const setSearchModalOpen = useHookstate(openAndCloseSearchDialogue)
const useRatingState = useHookstate(ratingState).get()
const useSteamRatingState = useHookstate(steamRatingState).get()
const searchComplete = useHookstate(searchCompletedState)
const removeFilters = async (filterHeader: string, filter: string) => {
const newFilter = filters[filterHeader as keyof typeof filters].replace(filter, '')
const finalFilter = { ...filters, [filterHeader]: newFilter }
await ChangeActiveFilterHeader(finalFilter, searchParams, setShowFiltersModuleState)
return await getWithFilters(
gamesState,
loadedState,
admin,
games.count.currentPage,
games.count.limit,
searchParams,
finalFilter,
useRatingState,
useSteamRatingState,
)
}
return showFiltersModule ? (
<Stack my={5} alignItems={{
base: 'start',
md: 'center'
}} direction={{
base: 'column',
md: 'row'
}} spacing={4}>
{searchParams.length >= 1 && (
<>
<h2>Search: </h2>
<Tag
size={'md'}
borderRadius="full"
variant="solid"
colorScheme="blue"
>
<TagLabel><Link
onMouseDown={() => setSearchModalOpen.set(true)}>{searchParams}</Link></TagLabel>
<TagCloseButton onMouseDown={async () => {
const newSearchParams = ""
ChangeActiveFilterHeader(filters, newSearchParams, setShowFiltersModuleState)
searchComplete.set(false)
return await getWithFilters(
gamesState,
loadedState,
admin,
games.count.currentPage,
games.count.limit,
newSearchParams,
filters,
useRatingState,
useSteamRatingState,
)
}} />
</Tag>
</>
)}
<Stack alignItems={{
base: 'start',
md: 'center'
}} direction={{
base: 'column',
md: 'row'
}} >
<h2>Active Filters: </h2>
{Object.keys(filters).map((filter: string, num: number) => {
const filterValues: string = filters[filter as keyof typeof filters]
const filterValuesLength: number = filterValues.length
return filterValuesLength >= 1 && (
<>
<HStack key={num}>
<h3 style={{ textTransform: 'capitalize' }}>{filter}</h3>
<HStack spacing={4}>
{filters[filter as keyof typeof filters].split(',').map((item: string, index) => (
<Tag
size={'md'}
key={index}
borderRadius="full"
variant="solid"
colorScheme="blue"
>
<TagLabel>{
item.includes('lte')
? item.replace('lte', '<= ')
: item.includes('gte') ? item.replace('gte', '>= ')
: item}
</TagLabel>
<TagCloseButton onMouseDown={() => removeFilters(filter, item)} />
</Tag>
))}
</HStack>
</HStack>
</>
)
})}
</Stack>
<Spacer />
<ResetFilterButton />
</Stack>
) : <></>
}
export default ActiveFilters

View File

@ -0,0 +1,77 @@
import React, { useRef } from 'react'
import agent from '../api/agent'
import { useHistory } from 'react-router-dom'
import { Data } from '../models/game'
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent, AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Button, Icon, Text
} from '@chakra-ui/react'
interface Props {
id: string
open: boolean
setOpen: Function
setLoading: Function
game: Data
}
export default function DeleteDialogue({
id,
open,
setOpen,
setLoading,
game,
}: Props) {
const history = useHistory()
const handleClose = () => {
setOpen(false)
}
const deleteGame = async (id: string) => {
setLoading(true)
try {
await agent.Games.delete(id)
setLoading(false)
handleClose()
history.push(`/games/`)
} catch (err) {
console.error(err)
setLoading(false)
}
}
const cancelRef = useRef(null)
return (
<AlertDialog isOpen={open} leastDestructiveRef={cancelRef} onClose={handleClose}>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize='lg' fontWeight='bold'>Delete?</AlertDialogHeader>
<AlertDialogBody>
Are you sure you would like to delete <strong>{game.title}</strong>?
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onMouseDown={handleClose}>
<Icon name='remove' mr='1' /> <Text mt='1'>No</Text>
</Button>
<Button
colorScheme='red'
onMouseDown={() => {
deleteGame(id)
}}
ml={3}
autoFocus
>
<Icon name='checkmark' mr='1' /> <Text mt='1'>Yes</Text>
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
)
}

View File

@ -0,0 +1,350 @@
import React, {MutableRefObject, useEffect} from 'react'
import {
Center,
Divider,
Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerOverlay, Flex,
Heading, Spacer,
Text,
} from '@chakra-ui/react'
import AdminMode from './authComponents/AdminMode'
import {StateMethods, useHookstate} from '@hookstate/core'
import {tagState} from '../stateManagement/tagState'
import {
controllerValues,
osValues,
playStatusValues, ratingStateValues,
ratingValues,
wineIntelValues,
yesNo
} from '../componentUtils/SelectValues'
import {retrieveTags} from '../componentUtils/retrieveTags'
import {gameDashboardState} from '../stateManagement/gameState'
import {Select} from 'chakra-react-select'
import getWithFilters from '../componentUtils/getWithFilters'
import {loadingState, showFiltersModuleState} from '../stateManagement/loadingState'
import {adminMode} from '../stateManagement/userState'
import ResetFilterButton from "./helperComponents/ResetFilterButton";
import {ChangeActiveFilterHeader} from "../componentUtils/changeActiveFilterHeader";
import {ratingState, steamRatingState} from "../stateManagement/ratingState";
interface Props {
filterIsOpen: boolean
filterOnClose: () => void
btnRef: MutableRefObject<null>
filterLoadingState: StateMethods<boolean, any>
}
function FilterBar({filterIsOpen, filterOnClose, btnRef}: Props) {
const gameState = useHookstate(gameDashboardState)
const games = gameState.get()
const {filters, searchParams} = games
const setShowFiltersModuleState = useHookstate(showFiltersModuleState)
const loadedState = useHookstate(loadingState)
const admin = useHookstate(adminMode).get()
const tagsState = useHookstate(tagState)
const tags = tagsState.get()
const setRatingState = useHookstate(ratingState)
const useRatingState = setRatingState.get()
const setSteamRatingState = useHookstate(steamRatingState)
const useSteamRatingState = setSteamRatingState.get()
const getTags = () => {
if (tags.developer.length < 2) return retrieveTags()
else return
}
useEffect(() => {
return () => {
getTags()
}
}, [tags.developer])
const filterToSelectDefaultValue = (filterName: string) => {
if (filters[filterName as keyof typeof filters] === "") return
return filters[filterName as keyof typeof filters].split(',').map(value => {
return {label: value, value: value}
})
}
const ratingToSelectDefaultValue = (value: string) => {
let label: string = '='
if (value === "") return
if (value === "[lte]") label = '<='
if (value === "[gte]") label = '>='
return { label, value }
}
const changeFilters = async (filterName: string, value: string) => {
const finalFilter = {...filters, [filterName]: value}
await ChangeActiveFilterHeader(finalFilter, searchParams, setShowFiltersModuleState)
return await getWithFilters(
gameState,
loadedState,
admin,
games.count.currentPage,
games.count.limit,
games.searchParams,
finalFilter,
useRatingState,
useSteamRatingState
)
}
return (
<Drawer
isOpen={filterIsOpen}
placement="right"
onClose={filterOnClose}
finalFocusRef={btnRef}
>
<DrawerOverlay/>
<DrawerContent>
<DrawerCloseButton/>
<DrawerHeader>
<Heading as={'h3'}>Filters</Heading>
</DrawerHeader>
<DrawerBody>
<AdminMode/>
<Divider my={'3'}/>
<Center>
<ResetFilterButton/>
</Center>
<Divider my={'3'}/>
<Text mb={'1'} fontSize={'xl'}>Series</Text>
<Select
size={'sm'}
defaultValue={filterToSelectDefaultValue("series")}
isClearable
isMulti
name="series"
onChange={(event) => {
const value = event.map(a => a.value).join(',')
changeFilters("series", value)
}}
options={tags.series}
/>
<Divider my={'3'}/>
<Flex mb={'1'}>
<Text mb={'1'} fontSize={'xl'}>Rating</Text>
<Spacer />
<Select
size={'sm'}
defaultValue={ratingToSelectDefaultValue(useRatingState)}
name="rating"
onChange={(event) => {
if (event === null) return
setRatingState.set(event.value)
}}
options={ratingStateValues}
chakraStyles={{
container: (provided) => ({
...provided,
width: "75px"
})
}}
/>
</Flex>
<Select
size={'sm'}
defaultValue={filterToSelectDefaultValue("rating")}
isClearable
name="rating"
onChange={(event) => {
if(event === null) return changeFilters("rating", "")
changeFilters("rating", event.value)
}}
options={ratingValues}
/>
<Divider my={'1'}/>
<Flex mb={'1'}>
<Text mb={'1'} fontSize={'xl'}>Steam Rating</Text>
<Spacer />
<Select
size={'sm'}
defaultValue={ratingToSelectDefaultValue(useSteamRatingState)}
name="steamRating"
onChange={(event) => {
if (event === null) return
setSteamRatingState.set(event.value)
}}
options={ratingStateValues}
chakraStyles={{
container: (provided) => ({
...provided,
width: "75px"
})
}}
/>
</Flex>
<Select
size={'sm'}
defaultValue={filterToSelectDefaultValue("steamRating")}
isClearable
name="steamRating"
onChange={(event) => {
if(event === null) return changeFilters("steamRating", "")
changeFilters("steamRating", event.value)
}}
options={ratingValues}
/>
<Divider my={'1'}/>
<Text mb={'1'} fontSize={'xl'}>Play Status</Text>
<Select
size={'sm'}
defaultValue={filterToSelectDefaultValue("playStatus")}
isClearable
isMulti
name="playStatus"
onChange={(event) => {
const value = event.map(a => a.value).join(',')
changeFilters("playStatus", value)
}}
options={playStatusValues}
/>
<Divider my={'1'}/>
<Text mb={'1'} fontSize={'xl'}>Genres</Text>
<Select
size={'sm'}
defaultValue={filterToSelectDefaultValue("genre")}
isClearable
isMulti
name="genre"
onChange={(event) => {
const value = event.map(a => a.value).join(',')
changeFilters("genre", value)
}}
options={tags.genres}
/>
<Divider my={'1'}/>
<Text mb={'1'} fontSize={'xl'}>Operating System</Text>
<Select
size={'sm'}
defaultValue={filterToSelectDefaultValue("os")}
isClearable
isMulti
name="os"
onChange={(event) => {
const value = event.map(a => a.value).join(',')
changeFilters("os", value)
}}
options={osValues}
/>
<Divider my={'1'}/>
<Text mb={'1'} fontSize={'xl'}>Store</Text>
<Select
size={'sm'}
defaultValue={filterToSelectDefaultValue("store")}
isClearable
isMulti
name="store"
onChange={(event) => {
const value = event.map(a => a.value).join(',')
changeFilters("store", value)
}}
options={tags.store}
/>
<Divider my={'1'}/>
<Text mb={'1'} fontSize={'xl'}>Controller Support</Text>
<Select
size={'sm'}
defaultValue={filterToSelectDefaultValue("controller")}
isClearable
isMulti
name="controller"
onChange={(event) => {
const value = event.map(a => a.value).join(',')
changeFilters("controller", value)
}}
options={controllerValues}
/>
<Divider my={'1'}/>
<Text mb={'1'} fontSize={'xl'}>Developer</Text>
<Select
size={'sm'}
defaultValue={filterToSelectDefaultValue("developer")}
isClearable
isMulti
name="developer"
onChange={(event) => {
const value = event.map(a => a.value).join(',')
changeFilters("developer", value)
}}
options={tags.developer}
/>
<Divider my={'1'}/>
<Text mb={'1'} fontSize={'xl'}>Publisher</Text>
<Select
size={'sm'}
defaultValue={filterToSelectDefaultValue("publisher")}
isClearable
isMulti
name="publisher"
onChange={(event) => {
const value = event.map(a => a.value).join(',')
changeFilters("publisher", value)
}}
options={tags.publisher}
/>
<Divider my={'1'}/>
<Text mb={'1'} fontSize={'xl'}>Do you own the Soundtrack?</Text>
<Select
size={'sm'}
defaultValue={filterToSelectDefaultValue("soundtrack")}
isClearable
isMulti
name="soundtrack"
onChange={(event) => {
const value = event.map(a => a.value).join(',')
changeFilters("soundtrack", value)
}}
options={yesNo}
/>
<Divider my={'1'}/>
<Text mb={'1'} fontSize={'xl'}>Does in work on Intel?</Text>
<Select
size={'sm'}
defaultValue={filterToSelectDefaultValue("intel")}
isClearable
isMulti
name="intel"
onChange={(event) => {
const value = event.map(a => a.value).join(',')
changeFilters("intel", value)
}}
options={wineIntelValues}
/>
<Divider my={'1'}/>
<Text mb={'1'} fontSize={'xl'}>Does is work with Wine?</Text>
<Select
size={'sm'}
defaultValue={filterToSelectDefaultValue("wine")}
isClearable
isMulti
name="wine"
onChange={(event) => {
const value = event.map(a => a.value).join(',')
changeFilters("wine", value)
}}
options={wineIntelValues}
/>
<Divider my={'1'}/>
</DrawerBody>
<DrawerFooter>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
export default FilterBar

17
src/components/Footer.tsx Normal file
View File

@ -0,0 +1,17 @@
import React from "react";
import { Box, Stack } from "@chakra-ui/react";
export default function Footer() {
// noinspection SpellCheckingInspection
return (
<Box as='footer' role='contentinfo' mx='auto' maxW='7xl' py='12' px={{ base: '4', md: '8' }}>
<Stack>
<Stack direction="row" spacing="4" align="center" justify="space-between">
</Stack>
</Stack>
</Box>
);
}

View File

@ -0,0 +1,156 @@
import React, { useEffect } from 'react'
import agent from '../api/agent'
import { useParams } from 'react-router'
import {
Box,
Button,
Container,
Flex,
Grid,
Heading,
Link as ChakraLink,
Spacer,
Stack,
Text,
useColorModeValue,
useDisclosure,
useMediaQuery,
} from '@chakra-ui/react'
import ImageSlider from './detailsComponents/ImageSlider'
import Summary from './detailsComponents/Summary'
import Features from './detailsComponents/Features'
import SystemRequirements from './detailsComponents/SystemRequirementsMenu'
import { Link } from 'react-router-dom'
import { detailedGameState } from '../stateManagement/gameState'
import { loadingState } from '../stateManagement/loadingState'
import { ImmutableObject, useHookstate } from '@hookstate/core'
import LoadingModal from './LoadingModal'
import SteamAndDeckLogo from './helperComponents/SteamAndDeckLogo'
import { ExternalLinkIcon } from '@chakra-ui/icons'
import { Data } from '../models/game'
import SummaryModal from './detailsComponents/SummaryModal'
import SystemRequirementsModal from './detailsComponents/SystemRequirementsModal'
const GameDetails = () => {
const gameState = useHookstate(detailedGameState)
const game: ImmutableObject<Data> = gameState.get().data
const loadedState = useHookstate(loadingState)
const loaded = loadedState.get()
const { id } = useParams<{ id: string }>()
const bg = useColorModeValue('white', 'gray.700')
const { isOpen: isSummaryOpen, onOpen: onSummaryOpen, onClose: onSummaryClose } = useDisclosure()
const { isOpen: isSystemOpen, onOpen: onSystemOpen, onClose: onSystemClose } = useDisclosure()
const [isSmallerThan768] = useMediaQuery('(max-width: 768px)')
useEffect(() => {
const getGame = async () => {
loadedState.set(false)
return await agent.Games.details(id)
}
getGame().then((result) => {
gameState.set(result)
document.title = result.data.title
loadedState.set(true)
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (!loaded) return <LoadingModal />
// @ts-ignore
// @ts-ignore
return (
<>
{isSummaryOpen ?
<SummaryModal isOpen={isSummaryOpen} onClose={onSummaryClose} game={game} />
: ''}
{isSystemOpen ?
<SystemRequirementsModal isOpen={isSystemOpen} onClose={onSystemClose} game={game} />
: ''}
<Container maxW="container.xl" mt="5">
<Flex direction={{ base: 'column', md: 'row' }}>
<Box p={2} alignSelf={{
base: 'center',
md: 'start'
}}>
<Heading as="h1">{game.title}</Heading>
</Box>
<Spacer />
<Stack spacing={[1, 5]} direction={'row'} alignSelf={{
base: 'center',
md: 'end'
}} pb="3">
{game.accessedBy[0].store.includes('Steam') && game.steamId.length > 0 &&
(<ChakraLink href={`steam://run/${game.steamId}`}>
<Button variant={'outline'} colorScheme="blue">
<Flex>
<SteamAndDeckLogo size={'1.5em'} />
<Text mt={'1'} ml={'2'}>
Open with Steam <ExternalLinkIcon pb={'1'} />
</Text>
</Flex>
</Button>
</ChakraLink>)}
<Button as={Link} to={`/games/${game._id}/edit`} colorScheme="blue">
<Text mt={'1'}>
Edit
</Text>
</Button>
</Stack>
</Flex>
<Grid
gridTemplateColumns={{
base: 'auto',
md: '3fr 2fr',
}}
gridTemplateRows={{
base: 'auto',
lg: 'auto',
}}
gridTemplateAreas={{
base: `'left' 'right'`,
md: `'left right'`,
}}
gap={3}
>
<Flex direction="column" gridArea="left">
<Box bg={bg} p="5" rounded="md">
<ImageSlider game={game} width={'100%'} />
</Box>
<Box bg={bg} p="5" mt="3" rounded="md">
{isSmallerThan768 ?
<Flex width="100%">
<Button colorScheme='blue' size='lg' width='100%' onMouseDown={onSummaryOpen}>
About This Game
</Button>
<Spacer />
</Flex>
: <Summary game={game} />
}
</Box>
</Flex>
<Flex direction="column" gridArea="right">
<Box bg={bg} p="5" rounded="md">
<Features game={game} />
</Box>
<Box bg={bg} mt="3" p="5" rounded="md">
{isSmallerThan768 ?
<Flex width="100%">
<Button colorScheme='blue' size='lg' width='100%' onMouseDown={onSystemOpen}>
System Requirements
</Button>
<Spacer />
</Flex>
: <SystemRequirements game={game} />
}
</Box>
</Flex>
</Grid>
</Container>
</>
)
}
export default GameDetails

597
src/components/GameForm.tsx Normal file
View File

@ -0,0 +1,597 @@
import React, {FormEvent, useEffect, useRef} from 'react'
import {useHistory, useParams} from 'react-router-dom'
import agent from '../api/agent'
import ObjectID from 'bson-objectid'
import {AccessedBy, Data, Os, SystemRequirements, WindowsOrMacOrLinux,} from '../models/game'
import {controllerValues, playStatusValues, wineIntelValues, yesNo,} from '../componentUtils/SelectValues'
import DeleteDialogue from './DeleteDialogue'
import {Options} from 'react-select'
import stringArrayToSelectObject from '../componentUtils/stringArrayToSelectObject'
import {
loadingAndContinueState,
loadingAndSaveState,
loadingState,
openAndCloseDeleteDialogue,
} from '../stateManagement/loadingState'
import {useHookstate} from '@hookstate/core'
import {tagState} from '../stateManagement/tagState'
import {retrieveTags} from '../componentUtils/retrieveTags'
import LoadingModal from './LoadingModal'
import {
Box,
Button,
Container,
FormControl,
FormLabel,
Heading,
InputGroup,
Spacer,
Stack,
Switch,
Text,
useColorModeValue,
} from '@chakra-ui/react'
import {CreatableSelect,} from 'chakra-react-select'
import {adminMode} from '../stateManagement/userState'
import {encode} from 'js-base64'
import SingleFieldInput from './formFields/SingleFieldInput'
import TextareaInput from './formFields/TextareaInput'
import SystemRequirementsTextareaInput from './formFields/SystemRequirementsTextareaInput'
import SelectInput from './formFields/SelectInput'
import CreatableSelectInput from './formFields/CreatableSelectInput'
import CheckboxInput from './formFields/CheckboxInput'
import { changeLoadState } from "../stateManagement/loadingState";
import SingleFieldNumericInput from './formFields/SingleFieldNumericInput'
const GameForm = () => {
const loadState = useHookstate(loadingState)
const loaded = loadState.get()
const submitLoadingState = useHookstate(loadingAndSaveState)
const submitLoading = submitLoadingState.get()
const history = useHistory()
const openState = useHookstate(openAndCloseDeleteDialogue)
const open = openState.get()
const moveOnState = useHookstate(loadingAndContinueState)
const moveOn = moveOnState.get()
const {id} = useParams<{ id: string }>()
const tagsState = useHookstate(tagState)
const tags = tagsState.get()
const bg = useColorModeValue('gray.50', 'transparent')
const admin = useHookstate(adminMode).get()
const gameState = useHookstate<Data>({
_id: '',
title: '',
series: '',
steamId: '',
slug: '',
frontImage: '',
screenshots: [{id: 0, full: '', thumbnail: ''}],
genre: [''],
os: {
windows: true,
mac: false,
linux: false,
android: false,
ios: false
},
wine: 'Not Tested',
controller: 'No Controller Support',
developer: [''],
publisher: [''],
releaseDate: '',
shortDesc: '',
reviews: '',
summary: '',
intel: 'Not Tested',
steamRating: 0,
systemRequirements: {
windows: {
minimum: '',
recommended: '',
},
mac: {
minimum: '',
recommended: '',
},
linux: {
minimum: '',
recommended: '',
},
},
accessedBy: [{
user: '',
store: ['Steam'],
playStatus: 'Never Played',
soundtrack: 'No',
rating: 0,
}],
createDate: '',
lastUpdateDate: '',
lastModifiedBy: '',
scrape: true,
})
const isMountedRef = useRef(false)
const game = gameState.get()
const gameId = game._id
const getGame = async (id: string, admin: boolean) => {
changeLoadState(true)
const result = admin ? await agent.GamesAdmin.details(id) : await agent.Games.details(id)
return result.data
}
useEffect(() => {
document.title = id ? `Edit ${game.title}` : 'Create Game'
})
const getTags = () => {
if (tags.developer.length < 2) return retrieveTags()
else return
}
useEffect(() => {
isMountedRef.current = true;
(async () => {
changeLoadState(true)
if (id) getGame(id, admin).then((result) => {
result.scrape = false
if (isMountedRef.current) gameState.set(result)
})
await getTags()
changeLoadState(false)
})()
return () => {
isMountedRef.current = false
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [admin])
const updateGame = async (id: string, data: Data) => {
try {
admin ? await agent.GamesAdmin.update(id, data) : await agent.Games.update(id, data)
} catch (err) {
return
}
}
const createGame = async (data: Data) => {
try {
admin ? await agent.GamesAdmin.create(data) : await agent.Games.create(data)
return true
} catch (err) {
return false
}
}
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if ((gameId === '' && !game.scrape) || (gameId.length >= 1 && admin)) {
const shortDesc = game.shortDesc.replace(/"/g, '\'')
const summary = game.summary.replace(/"/g, '\'')
const reviews = game.reviews.replace(/"/g, '\'')
const windowsRecommended = game.systemRequirements.windows.recommended.replace(/"/g, '\'')
const windowsMinimum = game.systemRequirements.windows.minimum.replace(/"/g, '\'')
const macMinimum = game.systemRequirements.mac.minimum.replace(/"/g, '\'')
const macRecommended = game.systemRequirements.mac.recommended.replace(/"/g, '\'')
const linuxMinimum = game.systemRequirements.linux.minimum.replace(/"/g, '\'')
const linuxRecommended = game.systemRequirements.linux.recommended.replace(/"/g, '\'')
gameState.merge({
shortDesc: encode(shortDesc),
summary: encode(summary),
reviews: encode(reviews),
systemRequirements: {
windows: {
minimum: encode(windowsMinimum),
recommended: encode(windowsRecommended),
},
mac: {
minimum: encode(macMinimum),
recommended: encode(macRecommended),
},
linux: {
minimum: encode(linuxMinimum),
recommended: encode(linuxRecommended),
},
},
})
}
//@ts-ignore
if (event.nativeEvent.submitter.dataset.name === 'save-and-view') {
submitLoadingState.set(false)
moveOnState.set(true)
}
//@ts-ignore
if (event.nativeEvent.submitter.dataset.name === 'save') {
moveOnState.set(false)
submitLoadingState.set(true)
}
if (gameId.length > 1) {
// @ts-ignore
updateGame(game._id, game).then(async () => {
changeLoadState(true)
await retrieveTags()
// @ts-ignore
if (event.nativeEvent.submitter.dataset.name === 'save-and-view') {
moveOnState.set(() => false)
changeLoadState(false)
return history.push(`/games/${gameId}`)
}
await getGame(gameId, admin)
changeLoadState(false)
})
// if (submitLoading) submitLoadingState.set(false)
} else {
const id = new ObjectID().toHexString()
const newGame = {
...game,
_id: id,
}
// @ts-ignore
createGame(newGame).then(async (data) => {
if (!data) {
changeLoadState(false)
return
// @ts-ignore
} else if (event.nativeEvent.submitter.dataset.name === 'save') {
changeLoadState(false)
return history.push(`/games/${newGame._id}/edit`)
// @ts-ignore
} else if (event.nativeEvent.submitter.dataset.name === 'save-and-view') {
changeLoadState(false)
return history.push(`/games/${newGame._id}`)
}
},
)
}
}
const handleInputChange = <G extends keyof Data>(name: string, value: Data[G]) => {
gameState.merge(() => ({[name]: value}))
}
const handleToggle = <G extends keyof Data>(name: G) => {
gameState.merge(() => ({[name]: !game[name]}))
}
const handleOSChange = <O extends keyof Os>(name: O) => {
gameState.os.merge(() => ({
[name]: !game.os[name],
}))
}
const handleSystemRequirements = <S extends keyof SystemRequirements,
WML extends keyof WindowsOrMacOrLinux>(
name: S,
minRec: WML,
value: string,
) => {
gameState.systemRequirements[name].merge(() => ({
[minRec]: value,
}))
}
const handleAccessedBy = <A extends keyof AccessedBy>(name: A, value: AccessedBy[A]) => {
gameState.accessedBy[0].merge(() => ({[name]: value}))
}
// @ts-ignore
const genres = stringArrayToSelectObject(game.genre)
// @ts-ignore
const store = stringArrayToSelectObject(game.accessedBy[0].store)
// @ts-ignore
const developer = stringArrayToSelectObject(game.developer)
// @ts-ignore
const publisher = stringArrayToSelectObject(game.publisher)
const screenshots = stringArrayToSelectObject(
game.screenshots.map((screenshot) => screenshot.full),
)
if (loaded) return <LoadingModal/>
if (open) return <DeleteDialogue
id={id}
open={open}
setOpen={openState.set}
setLoading={loadState.set}
//@ts-ignore
game={game}
/>
return (
<>
<Container maxW="container.md" mt="10">
{id ? <Heading as="h1" size="xl">Editing {game.title}</Heading> :
<Heading as="h1" size="xl">Add a Game to the Database</Heading>}
<form onSubmit={handleSubmit} style={{marginTop: '2rem'}}>
<Stack spacing={[1, 5]} direction={{base: 'column', sm: 'row'}} justify={'flex-end'}>
<Button
isLoading={submitLoading}
colorScheme="blue"
type="submit"
data-name={"save"}
>
Save
</Button>
<Button
isLoading={moveOn}
colorScheme="green"
type="submit"
data-name={"save-and-view"}
>
Save and View
</Button>
</Stack>
<InputGroup>
{!id || admin ? (
<SingleFieldNumericInput label={'Steam ID'} textField={'steamId'} field={game.steamId} bg={bg}
handleInputChange={handleInputChange} required={game.scrape}/>
) : ''}
{!id && (
<FormControl display="flex" ml={'3'} alignItems="center">
<FormLabel htmlFor="scrape" mb="0">Steam Scrape</FormLabel>
<Switch
isChecked={game.scrape}
onChange={() => {
handleToggle('scrape')
}}
name="scrape"
id="scrape"
/>
</FormControl>
)}
</InputGroup>
{(gameId === '' && !game.scrape) || (gameId.length >= 1 && admin) ? (
<SingleFieldInput label={'Title'} textField={'title'} field={game.title} bg={bg}
handleInputChange={handleInputChange} required={false}/>
) : ''}
<>
<FormControl mb="3">
<FormLabel>Series</FormLabel>
<Box
bg={bg}
border="1px"
borderColor="gray.500"
rounded="md">
<CreatableSelect
name="series"
isClearable
onChange={(values) => {
if (values === null) values = {value: '', label: ''}
if (values) {
handleInputChange('series', values.value)
}
}}
options={tags.series}
value={{value: game.series, label: game.series}}
/>
</Box>
</FormControl>
</>
{(gameId === '' && !game.scrape) || (gameId.length >= 1 && admin) ? (
<>
<SingleFieldInput label={'Front Image'} textField={'frontImage'} field={game.frontImage}
bg={bg}
handleInputChange={handleInputChange} required={false}/>
<FormControl mb="3">
<FormLabel>Screenshots</FormLabel>
<Box
bg={bg}
border="1px"
borderColor="gray.500"
rounded="md">
<CreatableSelect
name="screenshots"
isClearable
isMulti
onChange={(
values: Options<{
value: string
label: string
}>,
) => {
if (values) {
const valueArray = values
.reduce(
(
prev: {
id: number
full: string
thumbnail: string
}[],
curr,
index,
) => {
prev.push({
id: index,
full: curr.value,
thumbnail: curr.value,
})
return prev
},
[],
)
.filter((x) => x.full !== 'Copy/Paste Image URL and Press Enter')
handleInputChange('screenshots', valueArray)
}
}}
placeholder="Copy/Paste Image URL and Press Enter"
value={screenshots[0]?.value === '' ? [] : screenshots}
/>
</Box>
</FormControl>
</>
) : ''}
{(gameId === '' && !game.scrape) || (gameId.length >= 1) ?
<CreatableSelectInput label={'Genres'} textField={'genre'}
value={genres[0]?.value === '' ? [] : genres}
bg={bg}
handleInputChange={handleInputChange} options={tags.genres}/>
: ''}
<>
<CreatableSelectInput label={'Store'} textField={'store'}
value={store[0]?.value === '' ? [] : store}
bg={bg}
handleInputChange={handleAccessedBy} options={tags.store}/>
<SelectInput label={'Play Status'} textField={'playStatus'}
field={game.accessedBy[0].playStatus} bg={bg}
handleAccessedBy={handleAccessedBy} options={playStatusValues}/>
</>
{(gameId === '' && !game.scrape) || (gameId.length >= 1 && admin) ? (
<Box mt="5" mb="5">
<FormLabel>Operating Systems</FormLabel>
<Box
bg={bg}
border="1px"
borderColor="gray.500"
rounded="md"
p="2">
<Stack spacing={[1, 5]} direction={['column', 'row']}>
<CheckboxInput label={'Windows'} textField={'windows'} checked={game.os.windows}
defaultIsChecked={true} handleOSChange={handleOSChange}/>
<CheckboxInput label={'Mac OSX'} textField={'mac'} checked={game.os.mac}
defaultIsChecked={false}
handleOSChange={handleOSChange}/>
<CheckboxInput label={'Linux'} textField={'linux'} checked={game.os.linux}
defaultIsChecked={false}
handleOSChange={handleOSChange}/>
<CheckboxInput label={'Android'} textField={'android'} checked={game.os.android}
defaultIsChecked={false}
handleOSChange={handleOSChange}/>
<CheckboxInput label={'iOS'} textField={'ios'} checked={game.os.ios}
defaultIsChecked={false}
handleOSChange={handleOSChange}/>
</Stack>
</Box>
</Box>
) : ''}
<>
<SelectInput label={'Does it work with WINE?'} textField={'wine'} field={game.wine} bg={bg}
handleAccessedBy={handleInputChange} options={wineIntelValues}/>
<SelectInput label={'Does it work on an Intel Graphics Card?'} textField={'intel'}
field={game.intel}
bg={bg}
handleAccessedBy={handleInputChange} options={wineIntelValues}/>
</>
{(gameId === '' && !game.scrape) || (gameId.length >= 1 && admin) ? (
<>
<SelectInput label={'Does it support a controller?'} textField={'controller'}
field={game.controller}
bg={bg}
handleAccessedBy={handleInputChange} options={controllerValues}/>
<CreatableSelectInput label={'Developers'} textField={'developer'}
value={developer[0]?.value === '' ? [] : developer} bg={bg}
handleInputChange={handleInputChange} options={tags.developer}/>
<CreatableSelectInput label={'Publishers'} textField={'publisher'}
value={publisher[0]?.value === '' ? [] : publisher} bg={bg}
handleInputChange={handleInputChange} options={tags.publisher}/>
<SingleFieldInput label={'Release Date'} textField={'releaseDate'} field={game.releaseDate}
bg={bg}
handleInputChange={handleInputChange} required={game.scrape}/>
<TextareaInput label={'Short Description'} textField={'shortDesc'} field={game.shortDesc}
bg={bg}
handleInputChange={handleInputChange} rows={3}/>
<TextareaInput label={'Reviews'} textField={'reviews'} field={game.reviews} bg={bg}
handleInputChange={handleInputChange} rows={5}/>
<TextareaInput label={'Summary'} textField={'summary'} field={game.summary} bg={bg}
handleInputChange={handleInputChange} rows={5}/>
</>
) : ''}
<>
<SelectInput label={'Do you own the Soundtrack?'} textField={'soundtrack'}
field={game.accessedBy[0].soundtrack} bg={bg}
handleAccessedBy={handleAccessedBy} options={yesNo}/>
</>
{(gameId === '' && !game.scrape) || (gameId.length >= 1 && admin) ? (
<>
<Text align="center" fontSize="xl" mt="10">System Requirements</Text>
<Box>
<Text fontSize="lg" align="center">Windows</Text>
<SystemRequirementsTextareaInput label={'Windows Minimum'}
textField={'systemRequirements.windows.minimum'}
field={game.systemRequirements.windows.minimum} bg={bg}
handleSystemRequirements={handleSystemRequirements}
rows={3}/>
<SystemRequirementsTextareaInput label={'Windows Recommended'}
textField={'systemRequirements.windows.recommended'}
field={game.systemRequirements.windows.recommended}
bg={bg}
handleSystemRequirements={handleSystemRequirements}
rows={3}/>
<Text fontSize="lg" align="center">Mac OSX</Text>
<SystemRequirementsTextareaInput label={'Mac OSX Minimum'}
textField={'systemRequirements.mac.minimum'}
field={game.systemRequirements.mac.minimum} bg={bg}
handleSystemRequirements={handleSystemRequirements}
rows={3}/>
<SystemRequirementsTextareaInput label={'Mac OSX Recommended'}
textField={'systemRequirements.mac.recommended'}
field={game.systemRequirements.mac.recommended} bg={bg}
handleSystemRequirements={handleSystemRequirements}
rows={3}/>
<Text fontSize="lg" align="center">Linux</Text>
<SystemRequirementsTextareaInput label={'Linux Minimum'}
textField={'systemRequirements.linux.minimum'}
field={game.systemRequirements.linux.minimum} bg={bg}
handleSystemRequirements={handleSystemRequirements}
rows={3}/>
<SystemRequirementsTextareaInput label={'Linux Recommended'}
textField={'systemRequirements.linux.recommended'}
field={game.systemRequirements.linux.recommended}
bg={bg}
handleSystemRequirements={handleSystemRequirements}
rows={3}/>
</Box>
</>
) : ''}
<Stack spacing={[1, 5]} direction={{base: 'column', sm: 'row'}} align={{base: 'start'}}>
<Button
isLoading={submitLoading}
colorScheme="blue"
type="submit"
data-name={"save"}
>
Save
</Button>
<Button
isLoading={moveOn}
colorScheme="green"
type="submit"
data-name={"save-and-view"}
>
Save and View
</Button>
{id && (
<>
<Spacer/>
<Button
colorScheme="red"
onMouseDown={() => {
openState.set(true)
}}
>
Delete
</Button>
</>
)}
</Stack>
</form>
</Container>
</>
)
}
export default GameForm

View File

@ -0,0 +1,78 @@
// noinspection SpellCheckingInspection
import React, { useEffect } from 'react'
import { gameDashboardState } from '../stateManagement/gameState'
import { loadingState } from '../stateManagement/loadingState'
import { useHookstate } from '@hookstate/core'
import { Box, Center, Container, Divider, Heading, SimpleGrid, VStack } from '@chakra-ui/react'
import Pagination from './Pagination'
import { NavLink } from 'react-router-dom'
import { adminMode } from '../stateManagement/userState'
import ActiveFilters from './ActiveFilters'
import getWithFilters from "../componentUtils/getWithFilters";
import { ratingState, steamRatingState } from "../stateManagement/ratingState";
import GameThumbnailContextMenu from "./dashboardComponents/GameThumbnailContextMenu";
import DashboardLoadingModal from "./dashboardComponents/DashboardLoadingModal";
import ResetFilterButton from './helperComponents/ResetFilterButton'
const GameDashboard = () => {
const loadedState = useHookstate(loadingState)
const loaded = loadedState.get()
const gamesState = useHookstate(gameDashboardState)
const games = gamesState.get()
const admin = useHookstate(adminMode).get()
const useRatingState = useHookstate(ratingState).get()
const useSteamRatingState = useHookstate(steamRatingState).get()
useEffect(() => {
getWithFilters(gamesState, loadedState, admin, games.count.currentPage, games.count.limit, games.searchParams, games.filters, useRatingState, useSteamRatingState)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [games.success, admin])
return (
<>
<Container maxW="container.xl" mt="7">
<Pagination />
<ActiveFilters />
<Divider />
{!loaded ? <DashboardLoadingModal /> : games.count.totalGames === 0 ?
<Center h={'80vh'}>
<VStack spacing={10}>
<Heading as={'h1'} size={'3xl'}>No Results Found...</Heading>
<Divider />
{games.searchParams !== '' ?
<Box mt='10'>
<ResetFilterButton text='Clear Search and Filters' size='lg' variant='outline' colorScheme='blue' />
</Box>
:
<Box
rounded={'md'}
as={NavLink}
border={'1px'}
p={'3'}
fontSize={'2xl'}
to="/games/create">
Create a Game
</Box>
}
</VStack>
</Center>
:
<SimpleGrid columns={{ base: 1, sm: 2, md: 3 }} spacing={10} style={{ marginTop: '3rem' }}>
{games.data.map((game) => (
<GameThumbnailContextMenu
key={game._id}
//@ts-ignore
game={game} />
))}
</SimpleGrid>}
<Divider my={'1rem'} />
<Pagination />
</Container>
</>
)
}
export default GameDashboard

252
src/components/Header.tsx Normal file
View File

@ -0,0 +1,252 @@
import React, { useCallback, useEffect } from 'react'
import { useHookstate } from '@hookstate/core'
import { Link as RouteLink, useHistory, useLocation } from 'react-router-dom'
import {loadedPreferences, loggedInUser, logout} from '../stateManagement/userState'
import {
Box,
Button,
Flex,
Link,
HStack,
Stack,
useColorModeValue,
Menu,
MenuButton,
Avatar,
MenuList,
MenuItem,
MenuDivider,
Icon,
useColorMode,
Text,
Center,
VStack,
Tooltip,
Divider,
Heading,
Kbd,
Spacer,
useDisclosure,
useMediaQuery,
} from '@chakra-ui/react'
import { AddIcon, HamburgerIcon, Search2Icon, SettingsIcon } from '@chakra-ui/icons'
import SearchModal from './SearchModal'
import { openAndCloseSearchDialogue } from '../stateManagement/loadingState'
import AdminMode from './authComponents/AdminMode'
import MobileBar from './MobileBar'
import FilterBar from './FilterBar'
import { BiFilterAlt } from 'react-icons/bi'
import {MdOutlinePowerSettingsNew} from "react-icons/md";
export default function NavBar() {
const filterLoadingState = useHookstate(false)
const userState = useHookstate(loggedInUser)
const user = userState.get()
const setSearchModalOpen = useHookstate(openAndCloseSearchDialogue)
const searchModalOpen = setSearchModalOpen.get()
const { colorMode, toggleColorMode } = useColorMode()
const bgHeader = useColorModeValue('gray.100', 'gray.900')
const bgLink = useColorModeValue('gray.200', 'gray.700')
const location = useLocation()
const history = useHistory()
const { isOpen: mobileIsOpen, onOpen: mobileOnOpen, onClose: mobileOnClose } = useDisclosure()
const { isOpen: filterIsOpen, onOpen: filterOnOpen, onClose: filterOnClose } = useDisclosure()
const btnRef = React.useRef(null)
const [isSmallerThan768] = useMediaQuery('(max-width: 768px)')
const handleKeyPress = useCallback((event: any) => {
if (event.ctrlKey === true && event.key === ',') {
event.preventDefault()
setSearchModalOpen.set(true)
}
if (event.ctrlKey && event.key === '.' && location.pathname !== '/games') {
event.preventDefault()
history.push('/games')
}
if (event.ctrlKey && event.key === '/' && location.pathname !== '/games/create') {
event.preventDefault()
history.push('/games/create')
}
}, [setSearchModalOpen, location.pathname, history])
useEffect(() => {
document.addEventListener('keydown', handleKeyPress)
return () => {
document.removeEventListener('keydown', handleKeyPress)
}
}, [handleKeyPress])
return (
<>
{searchModalOpen ? <SearchModal/> : ''}
<Flex
zIndex={10000000}
bg={bgHeader}
m="0"
as="header"
px="2"
w="100%"
h={16}
top={0}
alignItems={'center'}
position={loadedPreferences.get().stickyNav ? "fixed" : "relative"}
justifyContent={'space-between'}
>
<HStack spacing={8} alignItems={'center'}>
<Box as={RouteLink} to="/">
Game Database
</Box>
<HStack
as={'nav'}
spacing={4}
display={{ base: 'none', md: 'flex' }}
>
<Link
px={2}
py={1}
rounded={'md'}
_hover={{
textDecoration: 'none',
bg: bgLink,
}}
as={RouteLink}
to="/games">
Games
</Link>
<Link
px={2}
py={1}
rounded={'md'}
_hover={{
textDecoration: 'none',
bg: bgLink,
}}
as={RouteLink}
to="/games/create">
Create Game
</Link>
</HStack>
</HStack>
<Spacer/>
{isSmallerThan768 ?
<>
<Button ref={btnRef} onMouseDown={mobileIsOpen ? mobileOnClose : mobileOnOpen}>
<HamburgerIcon/>
</Button>
<MobileBar mobileIsOpen={mobileIsOpen} mobileOnClose={mobileOnClose} btnRef={btnRef} bgLink={bgLink}
user={user} colorMode={colorMode} toggleColorMode={toggleColorMode}/>
</>
:
<Stack direction={'row'}>
{ user.role === 'admin' && <AdminMode/>}
<Tooltip hasArrow mr={'2'} label={'Filters'} aria-label={'Filters'}>
<Button
rounded={'full'}
bg={'transparent'}
ref={btnRef}
minW={0}
onMouseDown={async () => {
if (filterIsOpen) {
filterLoadingState.set(false)
filterOnClose()
} else {
filterLoadingState.set(true)
filterOnOpen()
}
}}>
<Icon as={BiFilterAlt}/>
</Button>
</Tooltip>
<FilterBar filterIsOpen={filterIsOpen} filterOnClose={filterOnClose} btnRef={btnRef} filterLoadingState={filterLoadingState} />
<Tooltip hasArrow mr={'2'} label={'Search: ctrl+s'} aria-label={'Search'}>
<Button
rounded={'full'}
onMouseDown={() => {
searchModalOpen ? setSearchModalOpen.set(false) : setSearchModalOpen.set(true)
}}
bg={'transparent'}
cursor={'pointer'}
minW={0}>
<Search2Icon/>
</Button>
</Tooltip>
<Menu>
<Tooltip hasArrow mr={'2'} label={'Settings'} aria-label={'Settings'}>
<MenuButton
as={Button}
rounded={'full'}
variant={'transparent'}
cursor={'pointer'}
minW={0}>
<SettingsIcon/>
</MenuButton>
</Tooltip>
<MenuList alignItems={'center'}>
<VStack>
{/*<Button onMouseDown={toggleColorMode} w={'70%'}>*/}
{/* {colorMode === 'light' ? <MoonIcon/> : <SunIcon/>}*/}
{/* {colorMode === 'light' ?*/}
{/* <Text mt={'1'} ml={'2'}>Dark</Text> :*/}
{/* <Text mt={'1'} ml={'2'}>Light</Text>}*/}
{/*</Button>*/}
<Button
variant={'solid'}
colorScheme={'blue'}
size={'md'}
leftIcon={<AddIcon/>}
as={RouteLink}
w={'70%'}
to="/games/create">
<Text mt="1">Create Game</Text>
</Button>
<Divider/>
<Heading as={'h3'} size={'md'}>Keyboard Shortcuts</Heading>
<HStack><Text fontSize={'sm'}>Search:</Text><span><Kbd>ctrl</Kbd> + <Kbd>,</Kbd></span></HStack>
<HStack><Text fontSize={'sm'}>Games Grid:</Text><span><Kbd>ctrl</Kbd> + <Kbd>.</Kbd></span></HStack>
<HStack><Text fontSize={'sm'}>Create
Game:</Text><span><Kbd>ctrl</Kbd> + <Kbd>/</Kbd></span></HStack>
</VStack>
</MenuList>
</Menu>
<Menu>
<Tooltip hasArrow mr={'2'} label={'User Info and Logout'} aria-label={'User Info'}>
<MenuButton
as={Button}
rounded={'full'}
variant={'transparent'}
cursor={'pointer'}
minW={0}>
<Avatar size={'sm'} src={user.avatar}/>
</MenuButton>
</Tooltip>
<MenuList alignItems={'center'}>
<br/>
<Center>
<Avatar
size={'2xl'}
src={user.avatar}
/>
</Center>
<br/>
<Center>
<p>{user.displayName}</p>
</Center>
<br/>
<MenuDivider/>
<MenuItem onMouseDown={() => history.push('/profile')}>Preferences</MenuItem>
<MenuItem onMouseDown={() => logout()}><Icon as={MdOutlinePowerSettingsNew} mr={'2'}/> <Text
mt={'1'}>Logout</Text></MenuItem>
</MenuList>
</Menu>
</Stack>
}
</Flex>
</>
)
}

21
src/components/Home.tsx Normal file
View File

@ -0,0 +1,21 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { VStack, Image, Button, Heading, Flex, Center } from '@chakra-ui/react'
const Home = () => {
return (
<Flex align={'center'} bgGradient={'linear(to-br, #314755, #26a0da)'} height={'100vh'}>
<Center w={'100%'}>
<VStack spacing={4} align={'center'} margin={'auto'}>
<Image src="/assets/Logo.svg" alt="Games Database Logo"/>
<Heading as="h2" fontSize={'1.7em'} fontWeight={'normal'}>Welcome to your Games Database</Heading>
<Button as={Link} to="/login" size='lg' variant='outline' fontSize='1.25rem'>
Login
</Button>
</VStack>
</Center>
</Flex>
)
}
export default Home

View File

@ -0,0 +1,21 @@
import React from 'react'
import { Center, CircularProgress } from '@chakra-ui/react'
export default function LoadingModal() {
return (
<>
<Center
pos="absolute"
w="100%"
h="100vh"
top={'0'}
right={'0'}
bottom={'0'}
left={'0'}
>
<CircularProgress isIndeterminate color="blue.500"/>
</Center>
</>
)
}

View File

@ -0,0 +1,134 @@
import React, { MutableRefObject } from 'react'
import {
Avatar, Button, Center, ColorMode,
Divider, Drawer,
DrawerBody,
DrawerCloseButton,
DrawerContent, DrawerFooter,
DrawerHeader,
DrawerOverlay, Icon,
Image, Link,
Stack, Text,
} from '@chakra-ui/react'
import AdminMode from './authComponents/AdminMode'
import { NavLink } from 'react-router-dom'
import Search from './Search'
import { AddIcon } from '@chakra-ui/icons'
import { logout } from '../stateManagement/userState'
import User from '../models/user'
import { AiOutlineLogout } from 'react-icons/ai'
import {useHookstate} from "@hookstate/core";
import {searchCompletedState} from "../stateManagement/loadingState";
import {gameDashboardState} from "../stateManagement/gameState";
import ResetFilterButton from "./helperComponents/ResetFilterButton";
interface Props {
mobileIsOpen: boolean
mobileOnClose: () => void
btnRef: MutableRefObject<null>
bgLink: "gray.200" | "gray.700"
user: User
colorMode: ColorMode
toggleColorMode: () => void
}
function MobileBar({ mobileIsOpen, mobileOnClose, btnRef, bgLink, user }: Props) {
const searchCompleted = useHookstate(searchCompletedState)
const gamesState = useHookstate(gameDashboardState)
const games = gamesState.get()
return (
<Drawer
isOpen={mobileIsOpen}
placement="right"
onClose={mobileOnClose}
finalFocusRef={btnRef}
>
<DrawerOverlay/>
<DrawerContent>
<DrawerCloseButton/>
<DrawerHeader>
<Image maxW="15rem" src="/assets/Logo.svg" alt="Games Database Logo"/>
</DrawerHeader>
<DrawerBody>
<AdminMode />
<Stack
as={'nav'}
spacing={4}
>
<Link
px={2}
py={1}
rounded={'md'}
_hover={{
textDecoration: 'none',
bg: bgLink,
}}
as={NavLink}
to="/games">
<Text align={'right'}>Games</Text>
</Link>
<Link
px={2}
py={1}
rounded={'md'}
_hover={{
textDecoration: 'none',
bg: bgLink,
}}
as={NavLink}
to="/games/create">
<Text align={'right'}>Create Game</Text>
</Link>
<Divider/>
<Search/>
{searchCompleted.get() && <Text>Games Found: {games.count.totalGames}</Text>}
<Divider/>
<Center><Avatar mr={'2'} size={'sm'} src={user.avatar}/><Text mt={1}>{user.displayName}</Text></Center>
<Link
px={2}
py={1}
rounded={'md'}
_hover={{
textDecoration: 'none',
bg: bgLink,
}}
as={NavLink}
to="/profile">
<Text align={'right'}>Preferences</Text>
</Link>
<Divider/>
{/*<Button onMouseDown={toggleColorMode} ml={'auto'}>*/}
{/* {colorMode === 'light' ? <MoonIcon/> : <SunIcon/>}*/}
{/* {colorMode === 'light' ?*/}
{/* <Text mt={'1'} ml={'2'}>Dark</Text> :*/}
{/* <Text mt={'1'} ml={'2'}>Light</Text>}*/}
{/*</Button>*/}
{/*<Divider/>*/}
<ResetFilterButton />
</Stack>
</DrawerBody>
<DrawerFooter>
<Button
variant={'solid'}
colorScheme={'blue'}
size={'md'}
leftIcon={<AddIcon/>}
as={NavLink}
mr={3}
to="/games/create">
<Text mt="1">Create Game</Text>
</Button>
<Button onMouseDown={() => logout()}><Icon as={AiOutlineLogout} mr={'2'}/> <Text
mt={'1'}>Logout</Text></Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
export default MobileBar

View File

@ -0,0 +1,18 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { Center, Flex, Heading, Button, VStack } from '@chakra-ui/react'
export default function NoGames() {
return (
<Flex align={'center'} bgGradient={'linear(to-br, #314755, #26a0da)'} height={'100vh'}>
<Center w={'100%'}>
<VStack spacing={4} align={'center'} margin={'auto'}>
{<Heading as="h2">You have not added any games yet.</Heading>}
<Button as={Link} to='/games/create' size='lg' variant='outline' fontSize='1.25rem'>
Click here to add some!
</Button>
</VStack>
</Center>
</Flex>
)
}

View File

@ -0,0 +1,205 @@
import React from 'react'
import {
Flex,
Grid,
GridItem,
IconButton,
NumberDecrementStepper,
NumberIncrementStepper,
NumberInput,
NumberInputField,
NumberInputStepper,
Select,
Text,
Tooltip,
useColorModeValue,
useMediaQuery,
} from '@chakra-ui/react'
import { ArrowLeftIcon, ArrowRightIcon, ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons'
import { useHookstate } from '@hookstate/core'
import { gameDashboardState } from '../stateManagement/gameState'
import { loadingState } from '../stateManagement/loadingState'
import { adminMode } from '../stateManagement/userState'
import getWithFilters from '../componentUtils/getWithFilters'
import { ratingState, steamRatingState } from "../stateManagement/ratingState";
export default function Pagination() {
const gamesState = useHookstate(gameDashboardState)
const games = gamesState.get()
const admin = useHookstate(adminMode).get()
const loadedState = useHookstate(loadingState)
const pageIndex = games.count.currentPage
const pageSize = games.count.limit
const pageCount = games.count.pages
const bg = useColorModeValue('gray.50', 'transparent')
const useRatingState = useHookstate(ratingState).get()
const useSteamRatingState = useHookstate(steamRatingState).get()
const [isSmallerThan768] = useMediaQuery('(max-width: 768px)')
const setPageSize = async (limit: number) => await getWithFilters(
gamesState,
loadedState,
admin,
games.count.currentPage,
limit,
games.searchParams,
games.filters,
useRatingState,
useSteamRatingState
)
const gotoPage = async (page: number) => await getWithFilters(
gamesState,
loadedState,
admin,
page,
games.count.limit,
games.searchParams,
games.filters,
useRatingState,
useSteamRatingState
)
const previousPage = async () => await getWithFilters(
gamesState,
loadedState,
admin,
games.pagination.prev.page,
games.count.limit,
games.searchParams,
games.filters,
useRatingState,
useSteamRatingState
)
const nextPage = async () => await getWithFilters(
gamesState,
loadedState,
admin,
games.pagination.next.page,
games.count.limit,
games.searchParams,
games.filters,
useRatingState,
useSteamRatingState
)
return (
<Grid mb={'6'} justifyItems={{
base: 'center'
}} alignItems={{
base: 'center'
}} templateAreas={{
base: `
"backArrows pageNumber forwardArrows"
"backArrows countPerPage forwardArrows"
"backArrows gamesFound forwardArrows"
`,
md: `"backArrows pageNumber goToPage countPerPage gamesFound forwardArrows"`
}}>
<GridItem area={"backArrows"}>
<Flex>
<Tooltip label="First Page">
<IconButton
aria-label={'First Page'}
onMouseDown={() => gotoPage(0)}
isDisabled={pageIndex <= 1}
icon={<ArrowLeftIcon h={3} w={3} />}
mr={4}
/>
</Tooltip>
<Tooltip label="Previous Page">
<IconButton
aria-label={'Previous Page'}
onMouseDown={previousPage}
isDisabled={pageIndex <= 1}
icon={<ChevronLeftIcon h={6} w={6} />}
/>
</Tooltip>
</Flex>
</GridItem>
<GridItem area={"pageNumber"}>
<Text mr={{ md: 8 }}>
Page{' '}
<Text fontWeight="bold" as="span">
{pageIndex}
</Text>{' '}
of{' '}
<Text fontWeight="bold" as="span">
{pageCount}
</Text>
</Text>
</GridItem>
{!isSmallerThan768 &&
<GridItem area={"goToPage"}>
<Text mb={'1'}>Go to Page:</Text>
<NumberInput
bg={bg}
rounded={'md'}
w={{ base: 20, md: 28 }}
min={1}
max={pageCount}
value={pageIndex}
onChange={(value) => {
const page = value ? Number(value) : 1
gotoPage(page)
}}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</GridItem>
}
<GridItem area={"countPerPage"}>
<Text mb={'1'}>Count Per Page:</Text>
<Select
ml={{
base: '4',
md: '0'
}}
bg={bg}
rounded={'md'}
w={{ base: 20, md: 32 }}
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value))
}}
>
{[10, 20, 30, 40, 50].map((pageSize) => (
<option key={pageSize} value={pageSize}>
{pageSize}
</option>
))}
</Select>
</GridItem>
<GridItem area={'gamesFound'}>
<Text>Games Found: {games.count.totalGames}</Text>
</GridItem>
<GridItem area={"forwardArrows"}>
<Flex justifySelf={'end'}>
<Tooltip label="Next Page">
<IconButton
aria-label={'Next Page'}
onMouseDown={nextPage}
isDisabled={pageIndex >= pageCount}
icon={<ChevronRightIcon h={6} w={6} />}
/>
</Tooltip>
<Tooltip label="Last Page">
<IconButton
aria-label={'Last Page'}
onMouseDown={() => gotoPage(pageCount)}
isDisabled={pageIndex >= pageCount}
icon={<ArrowRightIcon h={3} w={3} />}
ml={4}
/>
</Tooltip>
</Flex>
</GridItem>
</Grid>
)
}

88
src/components/Search.tsx Normal file
View File

@ -0,0 +1,88 @@
import React, { FormEvent } from 'react'
import { Button, FormControl, Input, InputGroup, InputRightElement, useColorModeValue } from '@chakra-ui/react'
import { SearchIcon } from '@chakra-ui/icons'
import { GameList } from '../models/game'
import { useHookstate } from '@hookstate/core'
import { gameDashboardState } from '../stateManagement/gameState'
import {
loadingAndSaveState,
loadingState,
openAndCloseSearchDialogue, searchCompletedState, showFiltersModuleState,
} from '../stateManagement/loadingState'
import { useHistory } from 'react-router-dom'
import { adminMode } from '../stateManagement/userState'
import getWithFilters from '../componentUtils/getWithFilters'
import {ChangeActiveFilterHeader} from "../componentUtils/changeActiveFilterHeader";
import {ratingState, steamRatingState} from "../stateManagement/ratingState";
export default function Search() {
const gamesState = useHookstate(gameDashboardState)
const games = gamesState.get()
const loadedState = useHookstate(loadingState)
const submitLoadingState = useHookstate(loadingAndSaveState)
const setShowFiltersModuleState = useHookstate(showFiltersModuleState)
const submitLoading = submitLoadingState.get()
const bg = useColorModeValue('gray.50', 'transparent')
const page = games.count.currentPage
const limit = games.count.limit
const setIsOpen = useHookstate(openAndCloseSearchDialogue)
const admin = useHookstate(adminMode).get()
const history = useHistory()
const useRatingState = useHookstate(ratingState).get()
const useSteamRatingState = useHookstate(steamRatingState).get()
const searchFinished= useHookstate(searchCompletedState)
const handleClose = () => {
setIsOpen.set(false)
}
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
submitLoadingState.set(true)
await getWithFilters(gamesState, loadedState, admin, page, limit, games.searchParams, games.filters, useRatingState, useSteamRatingState)
submitLoadingState.set(false)
games.searchParams.length > 0 && searchFinished.set(true)
handleClose()
return history.push('/games')
}
const handleInputChange = <G extends keyof GameList>(name: G, value: string) => {
gamesState.merge(() => ({ [name]: value }))
ChangeActiveFilterHeader(games.filters, games.searchParams, setShowFiltersModuleState)
}
return (
<>
<form onSubmit={handleSubmit}>
<FormControl mb="3">
<InputGroup>
<Input
autoFocus
id="searchParams"
name="searchParams"
bg={bg}
type="text"
border="1px"
borderColor="gray.500"
rounded="md"
placeholder="Search..."
value={games.searchParams}
onChange={(e) => {
handleInputChange('searchParams', e.target.value)
}}
/>
<InputRightElement>
<Button
isLoading={submitLoading}
type="submit"
colorScheme="blue"
>
<SearchIcon/>
</Button>
</InputRightElement>
</InputGroup>
</FormControl>
</form>
</>
)
}

View File

@ -0,0 +1,29 @@
import React from 'react'
import {
Modal, ModalBody, ModalCloseButton, ModalContent,
ModalHeader,
} from '@chakra-ui/react'
import { useHookstate } from '@hookstate/core'
import { openAndCloseSearchDialogue } from '../stateManagement/loadingState'
import Search from './Search'
export default function SearchModal() {
const setIsOpen = useHookstate(openAndCloseSearchDialogue)
const isOpen = setIsOpen.get()
const handleClose = () => {
setIsOpen.set(false)
}
return (
<Modal isOpen={isOpen} onClose={handleClose}>
<ModalContent>
<ModalHeader>Search</ModalHeader>
<ModalCloseButton onMouseDown={() => handleClose()}/>
<ModalBody>
<Search />
</ModalBody>
</ModalContent>
</Modal>
)
}

View File

@ -0,0 +1,15 @@
import React from 'react'
import { Checkbox, Text } from '@chakra-ui/react'
import { adminModeToggle, loggedInUser, adminMode } from '../../stateManagement/userState'
import { useHookstate } from '@hookstate/core'
export default function AdminMode() {
const user = useHookstate(loggedInUser).get()
const admin = useHookstate(adminMode).get()
return (
<Checkbox size={'lg'} onChange={() => adminModeToggle()} isChecked={admin} isDisabled={!(user.role === 'admin')}>
<Text mt={'1'}>Administrator Mode</Text>
</Checkbox>
)
}

View File

@ -0,0 +1,74 @@
import React from 'react'
import { useForm } from 'react-hook-form'
import {
Container,
Button,
FormControl,
FormLabel,
Input,
useColorModeValue, FormErrorMessage, useToast,
} from '@chakra-ui/react'
import agent from '../../api/agent'
import { IForgotPassword } from '../../models/user'
export default function ForgotPassword() {
const bg = useColorModeValue('gray.50', 'transparent')
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isDirty },
} = useForm()
const toast = useToast()
interface Message {
success: boolean
data: string
}
const onSubmit = handleSubmit(async (values) => {
const message = await agent.Account.forgot(values as IForgotPassword) as Message
toast({
title: 'Email Sent',
status: 'success',
description: message.data,
isClosable: true,
duration: 10000,
position: 'top'
})
})
return (
<Container style={{ marginTop: '7rem', paddingBottom: '5rem' }}>
<form onSubmit={onSubmit}>
<FormControl isInvalid={Boolean(errors.email)} mb={'3'}>
<FormLabel htmlFor="email">Email</FormLabel>
<Input
id="email"
bg={bg}
_placeholder={{ color: 'gray.500' }}
border="1px"
borderColor="gray.500"
rounded="md"
placeholder="Email"
{...register('email', {
required: 'Email is required',
pattern: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
})}
/>
<FormErrorMessage>
{errors.email?.type === 'required' && errors.email.message?.toString()}
{errors.email?.type === 'pattern' && 'Invalid Email Address'}
</FormErrorMessage>
</FormControl>
<br/>
<Button
type="submit"
disabled={!isDirty}
isLoading={isSubmitting}
>
Submit
</Button>
</form>
</Container>
)
}

View File

@ -0,0 +1,94 @@
import React from 'react'
import { useForm } from 'react-hook-form'
import { UserFormValues } from '../../models/user'
import { login } from '../../stateManagement/userState'
import {
Container,
Button,
FormControl,
FormLabel,
Input,
useColorModeValue, HStack, Center, FormErrorMessage,
} from '@chakra-ui/react'
import { Link } from 'react-router-dom'
export default function LoginForm() {
const bg = useColorModeValue('gray.50', 'transparent')
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting, isDirty },
} = useForm()
const onSubmit = handleSubmit(async (data) =>
await login(data as UserFormValues).catch(() =>
[
{
type: 'server',
name: 'password',
message: 'Invalid Email or Password',
},
].forEach(({ name, type, message }) => {
setError(name, { type, message })
}),
),
)
return (
<Container style={{ marginTop: '7rem', paddingBottom: '5rem' }}>
<form onSubmit={onSubmit}>
<FormControl isInvalid={Boolean(errors.email)} mb={'3'}>
<FormLabel htmlFor="email">Email</FormLabel>
<Input
id="email"
bg={bg}
_placeholder={{ color: 'gray.500' }}
border="1px"
borderColor="gray.500"
rounded="md"
placeholder="Email"
{...register('email', {
required: 'Email is required',
pattern: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
})}
/>
<FormErrorMessage>
{errors.email?.type === 'required' && errors.email.message?.toString()}
{errors.email?.type === 'pattern' && 'Invalid Email Address'}
</FormErrorMessage>
</FormControl>
<FormControl isInvalid={Boolean(errors.password)} mb={'3'}>
<FormLabel htmlFor="password">Password</FormLabel>
<Input
id="password"
bg={bg}
_placeholder={{ color: 'gray.500' }}
border="1px"
borderColor="gray.500"
rounded="md"
placeholder="Password"
type="password"
{...register('password', { required: 'You must specify a password' })}
/>
<FormErrorMessage>
{errors.password && errors.password.message?.toString()}
</FormErrorMessage>
</FormControl>
<br/>
<HStack>
<Button
type="submit"
disabled={!isDirty}
isLoading={isSubmitting}
>
Submit
</Button>
</HStack>
<Center mt={'5rem'}>
<Button as={Link} to={'/forgotpassword'}>Forgot Password</Button>
</Center>
</form>
</Container>
)
}

View File

@ -0,0 +1,120 @@
import React, { useRef } from 'react'
import { useForm } from 'react-hook-form'
import { IResetPassword } from '../../models/user'
import {
Button,
Container,
FormControl,
FormLabel,
Input, useToast, useColorModeValue, FormErrorMessage,
} from '@chakra-ui/react'
import agent from '../../api/agent'
import { useHistory, useParams } from 'react-router-dom'
interface Message {
success: boolean
data: string
}
interface URL {
token: string
}
export default function ResetPassword() {
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting, isDirty },
} = useForm()
const { token } = useParams<URL>()
const history = useHistory()
const toast = useToast()
const password = useRef({})
password.current = watch('password', '')
const onSubmit = handleSubmit(async (data) => {
const message = await agent.Account.reset(token, data as IResetPassword) as Message
if (message.success) {
setTimeout(() => history.push('/login'), 3000)
toast({
title: 'Password Reset',
status: 'success',
description: 'Your password has been reset. You will be redirected to login automatically',
isClosable: true,
duration: 5000,
position: 'top',
})
} else {
toast({
title: 'Password Not Reset',
status: 'error',
description: 'Something went wrong, please contact the website administrator',
isClosable: true,
duration: 10000,
position: 'top',
})
}
})
const bg = useColorModeValue('gray.50', 'transparent')
return (
<Container style={{ marginTop: '7rem', paddingBottom: '5rem' }}>
<form onSubmit={onSubmit}>
<FormControl isInvalid={Boolean(errors.password)} mb={'3'}>
<FormLabel htmlFor="password">Password</FormLabel>
<Input
id="password"
bg={bg}
_placeholder={{ color: 'gray.500' }}
border="1px"
borderColor="gray.500"
rounded="md"
placeholder="Password"
type="password"
{...register('password', {
required: 'You must specify a password',
minLength: {
value: 8,
message: 'Password must have at least 8 characters',
},
})}
/>
<FormErrorMessage>
{errors.password && errors.password.message?.toString()}
</FormErrorMessage>
</FormControl>
<FormControl isInvalid={Boolean(errors.repeat_password)} mb={'3'}>
<FormLabel htmlFor="repeat_password">Repeat Password</FormLabel>
<Input
id="repeat_password"
bg={bg}
_placeholder={{ color: 'gray.500' }}
border="1px"
borderColor="gray.500"
rounded="md"
placeholder="Repeat Password"
type="password"
{...register('repeat_password', {
validate: (value) =>
value === password.current || 'The passwords do not match',
})}
/>
<FormErrorMessage>
{errors.repeat_password && errors.repeat_password.message?.toString()}
</FormErrorMessage>
</FormControl>
<br/>
<Button
type="submit"
disabled={!isDirty}
isLoading={isSubmitting}
>
Submit
</Button>
</form>
</Container>
)
}

View File

@ -0,0 +1,16 @@
import React from 'react'
import { Center, CircularProgress } from '@chakra-ui/react'
export default function DashboardLoadingModal() {
return (
<>
<Center
w="100%"
mt={'1rem'}
>
<CircularProgress isIndeterminate color="blue.500"/>
</Center>
</>
)
}

View File

@ -0,0 +1,44 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { Data } from '../../models/game'
import { Link as ChakraLink, Flex, Heading, Image, LinkBox, LinkOverlay, Spacer, Text } from '@chakra-ui/react'
import GameRating from "../detailsComponents/GameRating";
import SteamAndDeckLogo from "../helperComponents/SteamAndDeckLogo";
interface Props {
game: Data
onToggle: () => void
playState: string
}
export default function GameThumbnail({ onToggle, game, playState }: Props) {
const gameLink = game.accessedBy[0].store.includes('Steam') && game.steamId.length > 0 ? game.steamId : game._id
return (
<>
<LinkBox
as={Link}
to={`/games/${gameLink}`}
onMouseOver={onToggle}
onMouseOut={onToggle}
>
<LinkOverlay>
<Image borderTopRadius="md" src={game.frontImage} alt={game.title} />
</LinkOverlay>
<Heading m={2} size="md" my="2">
{game.steamId !== '' ? `${game.steamId}: ${game.title}` : `${game.title}`}
</Heading>
<Text ml={2}>Play Status: {playState}</Text>
</LinkBox>
<Flex m={2}>
<GameRating game={game} size={'1em'} />
<Spacer />
{game.accessedBy[0].store.includes('Steam') && game.steamId.length > 0 && (
<ChakraLink href={`steam://run/${game.steamId}`}>
<SteamAndDeckLogo size={'2em'} />
</ChakraLink>
)}
</Flex>
</>
)
}

View File

@ -0,0 +1,69 @@
import React, { useState } from 'react';
import { Data } from "../../models/game";
import { ContextMenu } from 'chakra-ui-contextmenu';
import { MenuItem, MenuList } from '@chakra-ui/menu';
import GameThumbnail from "./GameThumbnail";
import { Box, MenuGroup, useDisclosure } from "@chakra-ui/react";
import GameThumbnailSlide from "./GameThumbnailSlide";
import { playStatusValues } from "../../componentUtils/SelectValues";
import agent from "../../api/agent";
import { Link } from "react-router-dom";
interface Props {
game: Data
}
const GameThumbnailContextMenu = ({ game }: Props) => {
const { isOpen, onToggle } = useDisclosure()
const [playState, setPlayState] = useState(game.accessedBy[0].playStatus)
const gameLink = game.steamId.length > 1 ? game.steamId : game._id
const rightClick = async (label: string, playStatus: string) => {
try {
setPlayState(playStatus)
await agent.Games.playStatus(game._id, {
accessedBy: [{
playStatus
}]
})
} catch (err) {
console.error(`${label}: ${err}`)
}
}
let mql = window.matchMedia('(max-width: 768px)');
return (
<ContextMenu<HTMLDivElement> key={game._id} renderMenu={() => (
<MenuList>
<MenuItem><Link to={`/games/${gameLink}`} target="_blank" rel="noopener noreferrer">Open in New Tab</Link></MenuItem>
<MenuGroup title={'Change PlayStatus'}>
{playStatusValues.map((value, index) => <MenuItem
key={index}
onClick={() => rightClick(value.label, value.value)}
>{value.label}</MenuItem>)}
</MenuGroup>
</MenuList>
)}>
{ref => <>
<Box
ref={ref}
border={'1px'}
borderColor={'gray.500'}
maxW={'sm'}
rounded={'md'}
transitionProperty={'box-shadow transform'}
transitionDuration={'1'}
transitionTimingFunction={'ease-in-out'}
_hover={{ boxShadow: '2xl', transform: 'translateY(-3px)' }}
>
<GameThumbnail onToggle={onToggle} game={game} playState={playState} />
</Box>
{(!mql.matches) && <GameThumbnailSlide isOpen={isOpen} game={game} />}
</>
}
</ContextMenu>
);
};
export default GameThumbnailContextMenu;

View File

@ -0,0 +1,36 @@
import React from 'react';
import {Box, Container, Heading, HStack, Slide} from "@chakra-ui/react";
import {Data} from "../../models/game";
import ImageSlider from "../detailsComponents/ImageSlider";
interface Props {
isOpen: boolean
game: Data
}
const GameThumbnailSlide = ({isOpen, game}: Props) => {
return (
<Slide direction="bottom" in={isOpen} unmountOnExit={true} style={{zIndex: 10}}>
<Box
p="40px"
color="white"
mt="4"
bgGradient={'linear(to-br, #314755, #26a0da)'}
borderTopRadius="md"
shadow="md"
>
<Container maxW="container.xl">
<HStack spacing='2rem'>
<ImageSlider game={game} width={'250px'}/>
<Box>
<Heading>{game.title}</Heading>
{game.shortDesc}
</Box>
</HStack>
</Container>
</Box>
</Slide>
);
};
export default GameThumbnailSlide;

View File

@ -0,0 +1,321 @@
import React, { useState as reactUseState } from 'react'
import { Badge, Box, Divider, Flex, Heading, Link, Text } from '@chakra-ui/react'
import { Data } from '../../models/game'
import { gameDashboardState } from '../../stateManagement/gameState'
import { ImmutableObject, useHookstate } from '@hookstate/core'
import { loadingState, showFiltersModuleState } from '../../stateManagement/loadingState'
import { useHistory } from 'react-router-dom'
import { adminMode } from '../../stateManagement/userState'
import getWithFilters from '../../componentUtils/getWithFilters'
import { ChangeActiveFilterHeader } from "../../componentUtils/changeActiveFilterHeader";
import GameRating from "./GameRating";
import GamePlayStatus from "./GamePlayStatus";
import { ratingState, steamRatingState } from "../../stateManagement/ratingState";
import { BiSave } from "react-icons/bi";
import { BsPencil } from "react-icons/bs";
interface Props {
game: ImmutableObject<Data>
}
export default function Features({ game }: Props) {
const gamesState = useHookstate(gameDashboardState)
const games = gamesState.get()
const setShowFiltersModuleState = useHookstate(showFiltersModuleState)
const loadedState = useHookstate(loadingState)
const admin = useHookstate(adminMode).get()
const history = useHistory()
const [editMode, setEditMode] = reactUseState(false)
const [playState, setPlayState] = reactUseState(game.accessedBy[0].playStatus);
const useRatingState = useHookstate(ratingState).get()
const useSteamRatingState = useHookstate(steamRatingState).get()
const handleOnClick = async () => {
await ChangeActiveFilterHeader(games.filters, games.searchParams, setShowFiltersModuleState)
await getWithFilters(gamesState, loadedState, admin, games.count.currentPage, games.count.limit, games.searchParams, games.filters, useRatingState, useSteamRatingState)
return history.push('/games')
}
return (
<Box mb="3">
<Heading as="h3" size="lg">Features and Information</Heading>
<Flex direction="column" m="2">
<Box>
<Badge colorScheme="blue" variant="outline" rounded="md" pl="2" pr="2">
<Text pt="1" fontSize="sm">
Rating:
</Text>
</Badge>
</Box>
<Box mt="1.5" ml="5" mr="2">
<GameRating game={game} size={'1.5em'} />
</Box>
</Flex>
<Divider orientation="horizontal" />
{game.accessedBy[0].store.includes('Steam') && game.steamId.length > 0 &&
(<>
<Flex direction="column" m="2">
<Box>
<Badge colorScheme="blue" variant="outline" rounded="md" pl="2" pr="2">
<Text pt="1" fontSize="sm">
Steam ID:
</Text>
</Badge>
</Box>
<Box mt="1.5" ml="5" mr="2">
<Link pt="1" href={`steam://run/${game.steamId}`}>{game.steamId}</Link>
</Box>
</Flex>
<Divider orientation="horizontal" />
</>)}
{game.series.length > 1 &&
(<>
<Flex direction="column" m="2">
<Box>
<Badge colorScheme="blue" variant="outline" rounded="md" pl="2" pr="2">
<Text pt="1" fontSize="sm">
Series:
</Text>
</Badge>
</Box>
<Box mt="1.5" ml="5" mr="2">
<Link onMouseDown={() => {
gamesState.filters.series.set(game.series)
handleOnClick()
}} pt="1">{game.series}</Link>
</Box>
</Flex>
<Divider orientation="horizontal" />
</>)}
<Flex direction="column" m="2">
<Box>
<Badge colorScheme="blue" variant="outline" rounded="md" pl="2" pr="2">
<Text pt="1" fontSize="sm">
Play Status:
</Text>
</Badge>
</Box>
<Box mt="1.5" ml="5" mr="2">
<Box display={'flex'}>
{editMode ?
<Box display={'flex'}>
<GamePlayStatus game={game} playState={playState} setPlayState={setPlayState} />
<Link ml={'1'} mt={'1'} onMouseDown={() => setEditMode(!editMode)}>
<BiSave size={'1.5rem'} />
</Link>
</Box>
:
<Box display={'flex'}>
<Link mr={'5px'} onMouseDown={() => {
gamesState.filters.playStatus.set(game.accessedBy[0].playStatus)
handleOnClick()
}} pt="1">
{playState}
</Link>
<Link mt={'1.5'} onMouseDown={() => setEditMode(!editMode)}>
<BsPencil />
</Link>
</Box>
}
</Box>
</Box>
</Flex>
<Divider orientation="horizontal" />
<Flex direction="column" m="2">
<Box>
<Badge colorScheme="blue" variant="outline" rounded="md" pl="2" pr="2">
<Text pt="1" fontSize="sm">
Genre:
</Text>
</Badge>
</Box>
<Box mt={'2'} ml={'4'}>
{game.genre.map((genre, index) => [
index > 0 && ", ",
<Link key={index} onMouseDown={() => {
gamesState.filters.genre.set(genre)
handleOnClick()
}}>
{genre}
</Link>
])}
</Box>
</Flex>
<Divider orientation="horizontal" />
<Flex direction="column" m="2">
<Box>
<Badge colorScheme="blue" variant="outline" rounded="md" pl="2" pr="2">
<Text pt="1" fontSize="sm">
Operating Systems:
</Text>
</Badge>
</Box>
<Box mt="1.5" ml="5" mr="2">
<Text pt="1">
{Object.keys(game.os).filter(os => {
//@ts-ignore
return game.os[os]
}).map((os, index) => {
const osStr = []
index > 0 && osStr.push(', ')
if (os === 'mac') osStr.push(<Link key={index} onMouseDown={() => {
gamesState.filters.os.set(os)
handleOnClick()
}}>Mac OSX</Link>)
else if (os === 'ios') osStr.push(<Link key={index} onMouseDown={() => {
gamesState.filters.os.set(os)
handleOnClick()
}}>iOS</Link>)
else osStr.push(<Link key={index} onMouseDown={() => {
gamesState.filters.os.set(os)
handleOnClick()
}}>{os.charAt(0).toUpperCase() + os.slice(1)}</Link>)
return osStr
})}
</Text>
</Box>
</Flex>
<Divider orientation="horizontal" />
<Flex direction="column" m="2">
<Box>
<Badge colorScheme="blue" variant="outline" rounded="md" pl="2" pr="2">
<Text pt="1" fontSize="sm">
Store:
</Text>
</Badge>
</Box>
<Box mt="1.5" ml="5" mr="2">
{game.accessedBy[0].store.map((store, index) => [
index > 0 && ", ",
<Link key={index} onMouseDown={() => {
gamesState.filters.store.set(store)
handleOnClick()
}}>
{store}
</Link>
])}
</Box>
</Flex>
<Divider orientation="horizontal" />
<Flex direction="column" m="2">
<Box>
<Badge colorScheme="blue" variant="outline" rounded="md" pl="2" pr="2">
<Text pt="1" fontSize="sm">
Developer:
</Text>
</Badge>
</Box>
<Box mt="1.5" ml="5" mr="2">
{game.developer.map((developer, index) => [
index > 0 && ", ",
<Link key={index} onMouseDown={() => {
gamesState.filters.developer.set(developer)
handleOnClick()
}}>
{developer}
</Link>
])}
</Box>
</Flex>
<Divider orientation="horizontal" />
<Flex direction="column" m="2">
<Box>
<Badge colorScheme="blue" variant="outline" rounded="md" pl="2" pr="2">
<Text pt="1" fontSize="sm">
Publisher:
</Text>
</Badge>
</Box>
<Box mt="1.5" ml="5" mr="2">
{game.publisher.map((publisher, index) => [
index > 0 && ", ",
<Link key={index} onMouseDown={() => {
gamesState.filters.publisher.set(publisher)
handleOnClick()
}}>
{publisher}
</Link>
])}
</Box>
</Flex>
<Divider orientation="horizontal" />
<Flex direction="column" m="2">
<Box>
<Badge colorScheme="blue" variant="outline" rounded="md" pl="2" pr="2">
<Text pt="1" fontSize="sm">
Controller Support:
</Text>
</Badge>
</Box>
<Box mt="1.5" ml="5" mr="2">
<Link onMouseDown={() => {
gamesState.filters.controller.set(game.controller)
handleOnClick()
}} pt="1">{game.controller}</Link>
</Box>
</Flex>
<Divider orientation="horizontal" />
<Flex direction="column" m="2">
<Box>
<Badge colorScheme="blue" variant="outline" rounded="md" pl="2" pr="2">
<Text pt="1" fontSize="sm">
Release Date:
</Text>
</Badge>
</Box>
<Box mt="1.5" ml="5" mr="2">
<Text pt="1">{game.releaseDate}</Text>
</Box>
</Flex>
<Divider orientation="horizontal" />
<Flex direction="column" m="2">
<Box>
<Badge colorScheme="blue" variant="outline" rounded="md" pl="2" pr="2">
<Text pt="1" fontSize="sm">
Soundtrack:
</Text>
</Badge>
</Box>
<Box mt="1.5" ml="5" mr="2">
<Link onMouseDown={() => {
gamesState.filters.soundtrack.set(game.accessedBy[0].soundtrack)
handleOnClick()
}} pt="1">{game.accessedBy[0].soundtrack}</Link>
</Box>
</Flex>
<Divider orientation="horizontal" />
<Flex direction="column" m="2">
<Box>
<Badge colorScheme="blue" variant="outline" rounded="md" pl="2" pr="2">
<Text pt="1" fontSize="sm">
Does it work with Intel?:
</Text>
</Badge>
</Box>
<Box mt="1.5" ml="5" mr="2">
<Link onMouseDown={() => {
gamesState.filters.intel.set(game.intel)
handleOnClick()
}} pt="1">{game.intel}</Link>
</Box>
</Flex>
<Divider orientation="horizontal" />
<Flex direction="column" m="2">
<Box>
<Badge colorScheme="blue" variant="outline" rounded="md" pl="2" pr="2">
<Text pt="1" fontSize="sm">
Does it work with WINE?:
</Text>
</Badge>
</Box>
<Box mt="1.5" ml="5" mr="2">
<Link onMouseDown={() => {
gamesState.filters.wine.set(game.wine)
handleOnClick()
}} pt="1">{game.wine}</Link>
</Box>
</Flex>
<Divider orientation="horizontal" />
</Box>
)
}

View File

@ -0,0 +1,51 @@
import React from 'react';
import { Data } from '../../models/game'
import agent from "../../api/agent";
import {playStatusValues} from "../../componentUtils/SelectValues";
import {Select} from "chakra-react-select";
interface Props {
game: Data
playState: string
setPlayState: any
}
const GamePlayStatus = ({game, playState, setPlayState}: Props) => {
const handleClick = async (label: string, playStatus: string) => {
try {
setPlayState(playStatus)
await agent.Games.playStatus(game._id, {
accessedBy: [{
playStatus
}]
})
} catch (err) {
console.error(`${label}: ${err}`)
}
}
return <Select
id={'playStatus'}
name={'playStatus'}
value={{
value: playState,
label: playState,
}}
onChange={(e) => {
if(e === null) return
handleClick(e.value, e.label)
}}
size={'sm'}
options={playStatusValues}
chakraStyles={{
container: (provided) => ({
...provided,
width: "150px"
})
}}
/>
};
export default GamePlayStatus;

View File

@ -0,0 +1,52 @@
import React, {useState} from 'react';
import {Data} from '../../models/game'
import Rating from 'react-rating'
import agent from "../../api/agent";
import {Flex, Link} from "@chakra-ui/react";
import {IoGameController, IoGameControllerOutline} from "react-icons/io5";
import {BiReset} from "react-icons/bi";
interface Props {
game: Data
size: string
}
const GameRating = ({game, size}: Props) => {
const steamRating = game.steamRating
const [rating, setRating] = useState(game.accessedBy[0].rating);
const handleClick = async (rating: number) => {
try {
setRating(rating)
await agent.Games.rating(game._id, {
accessedBy: [{
rating
}]
})
} catch (err) {
console.error(err)
}
}
const finalRating = rating === 0 ? steamRating : rating
return (
<Flex>
{/*@ts-ignore*/}
<Rating
start={0}
stop={10}
step={2}
fractions={2}
onMouseDown={handleClick}
initialRating={finalRating}
emptySymbol={<IoGameControllerOutline size={size}/>}
fullSymbol={<IoGameController size={size} color={rating === 0 ? '#2761A7' : '#64ABDE'}/>}
/>
<Link mt={'1'} ml={'1.5'} onMouseDown={() => handleClick(0)}><BiReset /></Link>
</Flex>
)
};
export default GameRating;

View File

@ -0,0 +1,25 @@
import React from 'react'
import { Data } from '../../models/game'
import { Carousel } from 'react-responsive-carousel'
import { Image } from '@chakra-ui/react'
import { ImmutableObject } from '@hookstate/core'
interface Props {
game: ImmutableObject<Data>
width: string
}
export default function ImageSlider({ game, width }: Props) {
const { frontImage, screenshots } = game
const newFrontImage = { id: 500, full: frontImage, thumbnail: frontImage }
const combinedImages = [newFrontImage, ...screenshots]
return (
<Carousel useKeyboardArrows width={width} swipeable emulateTouch autoPlay dynamicHeight={false} showThumbs={false} autoFocus infiniteLoop interval={2000}>
{combinedImages.map(({ id, full }) => (
<Image key={id} borderTopRadius="md" src={full} alt="Game Image" />
),
)}
</Carousel>
)
}

View File

@ -0,0 +1,35 @@
import React from 'react'
import { Data } from '../../models/game'
import { Interweave } from 'interweave'
import { Divider, Box, Heading } from '@chakra-ui/react'
import { ImmutableObject } from '@hookstate/core'
interface Props {
game: ImmutableObject<Data>
}
export default function Summary({ game }: Props) {
const { summary, reviews } = game
return (
<Box>
{reviews.length >= 1 && (
<>
<Heading as="h1">Reviews</Heading>
<Divider m="3" />
<Interweave
content={reviews}
allowAttributes
allowElements
/>
</>
)}
<Heading mt={reviews.length >= 1 ? '10' : ''} as="h1">About This Game</Heading>
<Divider m="3" />
<Interweave
content={summary}
allowAttributes
allowElements
/>
</Box>
)
}

View File

@ -0,0 +1,34 @@
import React from 'react'
import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay } from '@chakra-ui/react'
import Summary from './Summary'
import { ImmutableObject } from '@hookstate/core'
import { Data } from '../../models/game'
interface Props {
isOpen: boolean,
onClose: () => void,
game: ImmutableObject<Data>
}
const SummaryModal = ({ isOpen, onClose, game }: Props) => {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent maxW={'90%'}>
<ModalCloseButton />
<ModalBody>
<Summary game={game} />
</ModalBody>
<ModalFooter>
<Button colorScheme='blue' onMouseDown={onClose}>
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}
export default SummaryModal

View File

@ -0,0 +1,70 @@
import React from 'react'
import { TabList, Tab, TabPanel, TabPanels, Tabs, Heading, Box } from '@chakra-ui/react'
import { Data } from '../../models/game'
import { Interweave } from 'interweave'
import { ImmutableObject } from '@hookstate/core'
interface Props {
game: ImmutableObject<Data>
}
export default function SystemRequirements({ game }: Props) {
const { windows, mac, linux } = game.systemRequirements
let winRec, macMin, macRec, linMin, linRec
windows.recommended.length > 1 ? winRec = false : winRec = true
mac.minimum.length > 1 ? macMin = false : macMin = true
mac.recommended.length > 1 ? macRec = false : macRec = true
linux.minimum.length > 1 ? linMin = false : linMin = true
linux.recommended.length > 1 ? linRec = false : linRec = true
return (
<Box>
<Heading as='h3' size='lg'>System Requirements</Heading>
<Tabs isLazy mt='3'>
<TabList>
<Tab>Windows</Tab>
<Tab isDisabled={macMin}>Mac</Tab>
<Tab isDisabled={linMin}>Linux</Tab>
</TabList>
<TabPanels>
<TabPanel>
<Tabs isLazy>
<TabList>
<Tab>Minimum</Tab>
<Tab isDisabled={winRec}>Recommended</Tab>
</TabList>
<TabPanels>
<TabPanel>{<Interweave content={windows.minimum} />}</TabPanel>
<TabPanel>{<Interweave content={windows.recommended} />}</TabPanel>
</TabPanels>
</Tabs>
</TabPanel>
<TabPanel>
<Tabs isLazy>
<TabList>
<Tab isDisabled={macMin}>Minimum</Tab>
<Tab isDisabled={macRec}>Recommended</Tab>
</TabList>
<TabPanels>
<TabPanel>{<Interweave content={mac.minimum} />}</TabPanel>
<TabPanel>{<Interweave content={mac.recommended} />}</TabPanel>
</TabPanels>
</Tabs>
</TabPanel>
<TabPanel>
<Tabs isLazy>
<TabList>
<Tab isDisabled={linMin}>Minimum</Tab>
<Tab isDisabled={linRec}>Recommended</Tab>
</TabList>
<TabPanels>
<TabPanel>{<Interweave content={linux.minimum} />}</TabPanel>
<TabPanel>{<Interweave content={linux.recommended} />}</TabPanel>
</TabPanels>
</Tabs>
</TabPanel>
</TabPanels>
</Tabs>
</Box>
)
}

View File

@ -0,0 +1,34 @@
import React from 'react'
import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay } from '@chakra-ui/react'
import { ImmutableObject } from '@hookstate/core'
import { Data } from '../../models/game'
import SystemRequirements from './SystemRequirementsMenu'
interface Props {
isOpen: boolean,
onClose: () => void,
game: ImmutableObject<Data>
}
const SummaryModal = ({ isOpen, onClose, game }: Props) => {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent maxW={'90%'}>
<ModalCloseButton />
<ModalBody>
<SystemRequirements game={game} />
</ModalBody>
<ModalFooter>
<Button colorScheme='blue' onMouseDown={onClose}>
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}
export default SummaryModal

View File

@ -0,0 +1,26 @@
import React from 'react'
import { Checkbox, Text } from '@chakra-ui/react'
interface Props {
label: string
textField: string
checked: boolean
defaultIsChecked: boolean
handleOSChange: any
}
const CheckboxInput = ({ label, textField, checked, defaultIsChecked, handleOSChange }: Props) => {
return (
<Checkbox
defaultChecked={defaultIsChecked}
isChecked={checked}
onChange={() => {
handleOSChange(textField)
}}
name={textField}
spacing="1rem"
><Text mt="1">{label}</Text></Checkbox>
)
}
export default CheckboxInput

View File

@ -0,0 +1,46 @@
import React from 'react'
import { Box, FormControl, FormLabel } from '@chakra-ui/react'
import { CreatableSelect } from 'chakra-react-select'
interface Props {
label: string
textField: string
value: any
bg: string
handleInputChange: any
options: any
}
const CreatableSelectInput = ({ label, textField, value, bg, handleInputChange, options}: Props) => {
return (
<FormControl mb="3">
<FormLabel>{label}</FormLabel>
<Box
bg={bg}
border="1px"
borderColor="gray.500"
rounded="md">
<CreatableSelect
name={textField}
isClearable
isMulti
onChange={(
values: any,
) => {
if (values) {
const valueArray = values.reduce((prev: string[], curr: any) => {
prev.push(curr.value)
return prev
}, [])
handleInputChange(textField, valueArray)
}
}}
value={value}
options={options}
/>
</Box>
</FormControl>
)
}
export default CreatableSelectInput

View File

@ -0,0 +1,42 @@
import React from 'react'
import {Box, FormControl, FormLabel} from '@chakra-ui/react'
import {Select} from 'chakra-react-select'
interface Props {
label: string
textField: string
field: string
bg: string
handleAccessedBy: any
options: { value: string, label: string }[]
width?: string
}
const SelectInput = ({label, textField, field, bg, handleAccessedBy, options, width = ''}: Props) => {
return (
<FormControl mb="3">
<FormLabel>{label}</FormLabel>
<Box
bg={bg}
w={width}
border="1px"
borderColor="gray.500"
rounded="md">
<Select
id={textField}
name={textField}
onChange={(values) => {
if (values) handleAccessedBy(textField, values.value)
}}
options={options}
value={{
value: field,
label: field,
}}
/>
</Box>
</FormControl>
)
}
export default SelectInput

View File

@ -0,0 +1,37 @@
import React from 'react'
import { FormControl, FormLabel, Input } from '@chakra-ui/react'
import { Data } from '../../models/game'
interface Props {
label: string
textField: string
field: string
bg: string
handleInputChange: <G extends keyof Data>(name: string, value: Data[G]) => void
required: boolean
}
const SingleFieldInput = ({ label, textField, field, bg, handleInputChange, required }: Props) => {
return (
<FormControl mb="3">
<FormLabel>{label}</FormLabel>
<Input
id={textField}
name={textField}
bg={bg}
type="text"
border="1px"
borderColor="gray.500"
rounded="md"
value={field}
required={required}
onChange={(e) => {
handleInputChange(textField, e.target.value)
}}
/>
</FormControl>
)
}
export default SingleFieldInput

View File

@ -0,0 +1,39 @@
import React from 'react'
import { FormControl, FormLabel, Input } from '@chakra-ui/react'
import { Data } from '../../models/game'
interface Props {
label: string
textField: string
field: string
bg: string
handleInputChange: <G extends keyof Data>(name: string, value: Data[G]) => void
required: boolean
}
const SingleFieldNumericInput = ({ label, textField, field, bg, handleInputChange, required }: Props) => {
return (
<FormControl mb="3">
<FormLabel>{label}</FormLabel>
<Input
id={textField}
name={textField}
bg={bg}
type="text"
inputMode="numeric"
pattern="[0-9]*"
border="1px"
borderColor="gray.500"
rounded="md"
value={field}
required={required}
onChange={(e) => {
handleInputChange(textField, e.target.value)
}}
/>
</FormControl>
)
}
export default SingleFieldNumericInput

View File

@ -0,0 +1,41 @@
import React, {useMemo, useRef} from 'react'
import {FormControl, FormLabel} from '@chakra-ui/react'
import JoditEditor from "jodit-react";
interface Props {
label: string
textField: string
field: string
bg: string
handleSystemRequirements: any
rows: number
}
const SystemRequirementsTextareaInput = ({label, textField, field, handleSystemRequirements}: Props) => {
const arrText = textField.split('.')
const editor = useRef(null);
const config = useMemo(() =>
({
readonly: false, // all options from https://xdsoft.net/jodit/doc/,
theme: 'dark',
}),
[]
);
return (
<FormControl mb="3">
<FormLabel>{label}</FormLabel>
<JoditEditor
ref={editor}
value={field}
config={config}
onChange={(value) => {
handleSystemRequirements(arrText[1], arrText[2], value)
}}
/>
</FormControl>
)
}
export default SystemRequirementsTextareaInput

View File

@ -0,0 +1,42 @@
import React, {useMemo, useRef} from 'react'
import { FormControl, FormLabel } from '@chakra-ui/react'
import { Data } from '../../models/game'
import JoditEditor from "jodit-react";
interface Props {
label: string
textField: string
field: string
bg: string
handleInputChange: <G extends keyof Data>(name: string, value: Data[G]) => void
rows: number
}
const TextareaInput = ({ label, textField, field, handleInputChange }: Props) => {
const editor = useRef(null);
const config = useMemo( () =>
({
readonly: false, // all options from https://xdsoft.net/jodit/doc/,
theme: 'dark',
}),
[]
);
return (
<FormControl mb="3">
<FormLabel>{label}</FormLabel>
<JoditEditor
ref={editor}
value={field}
config={config}
onChange={(value) => {
handleInputChange(textField, value)
}}
/>
</FormControl>
)
}
export default TextareaInput

View File

@ -0,0 +1,67 @@
import React from 'react'
import { gameDashboardState } from '../../stateManagement/gameState'
import { useHookstate } from "@hookstate/core";
import { loadingState, searchCompletedState, showFiltersModuleState } from "../../stateManagement/loadingState";
import getWithFilters from "../../componentUtils/getWithFilters";
import { adminMode } from "../../stateManagement/userState";
import { Button } from '@chakra-ui/react';
import { ratingState, steamRatingState } from "../../stateManagement/ratingState";
interface Props {
text?: string
size?: string
variant?: string
colorScheme?: string
}
const ResetFilterButton = ({ text = "Reset Search and Filters", size = 'md', variant = 'solid', colorScheme = 'gray' }: Props) => {
const gameState = useHookstate(gameDashboardState)
const games = gameState.get()
const admin = useHookstate(adminMode).get()
const loadedState = useHookstate(loadingState)
const useRatingState = useHookstate(ratingState).get()
const useSteamRatingState = useHookstate(steamRatingState).get()
const searchComplete = useHookstate(searchCompletedState)
const setShowFiltersModuleState = useHookstate(showFiltersModuleState)
const resetFilters = async () => {
const searchParams = ''
const finalFilter = {
controller: "",
developer: "",
genre: "",
intel: "",
os: "",
playStatus: "",
publisher: "",
series: "",
soundtrack: "",
store: "",
wine: "",
rating: "",
steamRating: "",
}
searchComplete.set(false)
setShowFiltersModuleState.set(false)
return await getWithFilters(
gameState,
loadedState,
admin,
games.count.currentPage,
games.count.limit,
searchParams,
finalFilter,
useRatingState,
useSteamRatingState,
)
}
return (
<Button onMouseDown={resetFilters} size={size} variant={variant} colorScheme={colorScheme}>
{text}
</Button>
)
}
export default ResetFilterButton;

View File

@ -0,0 +1,105 @@
import React from 'react';
import {Box} from "@chakra-ui/react";
interface ISteamAndDeckLogo {
size: string
}
const SteamAndDeckLogo = (props: ISteamAndDeckLogo) => {
return (
<div>
<Box
as="svg"
role="img"
width={props.size}
height={props.size}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1080 1080"
fill="#1f2127"
_hover={{
fill: "brand.accent",
}}
>
<title>SteamAndDeckLogo</title>
<path
fill="url(#_Radial1)"
d="M1080 216C1080 96.786 983.214 0 864 0H216C96.786 0 0 96.786 0 216v648c0 119.214 96.786 216 216 216h648c119.214 0 216-96.786 216-216V216z"
></path>
<clipPath id="_clip2">
<path
d="M1080 216C1080 96.786 983.214 0 864 0H216C96.786 0 0 96.786 0 216v648c0 119.214 96.786 216 216 216h648c119.214 0 216-96.786 216-216V216z"></path>
</clipPath>
<g clipPath="url(#_clip2)">
<path
fill="url(#_Linear3)"
d="M326.9 728.816C280.975 678.813 253 612.591 253 540c0-155.64 128.6-282 287-282s287 126.36 287 282-128.6 282-287 282c-50.843 0-98.616-13.018-140.051-35.845l150.691-61.717-30.603-74.723L326.9 728.816z"
transform="rotate(46.353 540 540)"
></path>
<path
d="M541.381 95.002C786.349 95.748 985 294.858 985 540S786.349 984.252 541.381 984.998v-99.296C731.547 884.957 885.705 730.339 885.705 540S731.547 195.043 541.381 194.298V95.002z"></path>
<circle
cx="708"
cy="479"
r="61"
fill="#1F3E6B"
transform="matrix(.7377 0 0 .7377 104.705 102.639)"
></circle>
<path
fill="#1F3E6B"
d="M627 363c51.328 0 93 41.672 93 93s-41.672 93-93 93-93-41.672-93-93 41.672-93 93-93zm0 34.428c32.327 0 58.572 26.245 58.572 58.572S659.327 514.572 627 514.572 568.428 488.327 568.428 456s26.245-58.572 58.572-58.572z"
></path>
<path
fill="#1F3E6B"
d="M534 564H376l39.5-158h79L534 564z"
transform="matrix(-.48675 -.5974 .77525 -.63166 372.779 1129.97)"
></path>
<path
fill="#1F3E6B"
d="M406 553.837c38.127 0 69.081 30.955 69.081 69.082C475.081 661.046 444.127 692 406 692s-69.081-30.954-69.081-69.081 30.954-69.082 69.081-69.082zm0 17.271c28.595 0 51.811 23.215 51.811 51.811 0 28.595-23.216 51.811-51.811 51.811s-51.811-23.216-51.811-51.811c0-28.596 23.216-51.811 51.811-51.811z"
transform="translate(19.825 13)"
></path>
<path
fill="#1F3E6B"
d="M296.936 630.919c-13.657-12.11-24.394-25.672-31.847-40.082H410v40.082H296.936z"
transform="matrix(1.31489 .58767 -.82202 1.83925 393.401 -726.359)"
></path>
<circle
cx="410"
cy="630.919"
r="40.081"
fill="#1F3E6B"
transform="translate(15.825 5)"
></circle>
</g>
<defs>
<radialGradient
id="_Radial1"
cx="0"
cy="0"
r="1"
gradientTransform="matrix(540 0 0 540 540 540)"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#2A91D7"></stop>
<stop offset="1" stopColor="#071027"></stop>
</radialGradient>
<linearGradient
id="_Linear3"
x1="0"
x2="1"
y1="0"
y2="0"
gradientTransform="matrix(574 0 0 564 253 540)"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#B75ECE"></stop>
<stop offset="0.51" stopColor="#2F94F2" stopOpacity="0.7"></stop>
<stop offset="1" stopColor="#00A6FF" stopOpacity="0.6"></stop>
</linearGradient>
</defs>
</Box>
</div>
);
};
export default SteamAndDeckLogo;

View File

@ -0,0 +1,161 @@
import React, { useRef } from 'react'
import { useForm } from 'react-hook-form'
import { UserFormValues } from '../../models/user'
import { createUser } from '../../stateManagement/userState'
import {
Alert,
AlertDescription,
AlertIcon,
Button,
Container,
FormControl, FormErrorMessage,
FormLabel,
Input, useColorModeValue,
} from '@chakra-ui/react'
export default function CreateUser() {
const {
register,
handleSubmit,
setError,
watch,
formState: { errors, isSubmitting, isDirty },
} = useForm()
const password = useRef({})
password.current = watch('password', '')
const onSubmit = handleSubmit(async (data) => {
data.role = 'user'
return await createUser(data as UserFormValues).catch(() =>
[
{
type: 'server',
name: 'password',
message: 'Invalid Email or Password',
},
].forEach(({ name, type, message }) => {
setError(name, { type, message })
})
)
})
const bg = useColorModeValue('gray.50', 'transparent')
return (
<Container style={{ marginTop: '7rem', paddingBottom: '5rem' }}>
<form onSubmit={onSubmit}>
<FormControl isInvalid={Boolean(errors.name)} mb={'3'}>
<FormLabel htmlFor='name'>Full Name</FormLabel>
<Input
id='name'
bg={bg}
_placeholder={{ color: 'gray.500' }}
border="1px"
borderColor="gray.500"
rounded="md"
placeholder='Full Name'
{...register('name', { required: 'Name is required' })}
/>
{errors.name && <FormErrorMessage>{ errors.name.message?.toString()}</FormErrorMessage>}
</FormControl>
<FormControl isInvalid={Boolean(errors.displayName)} mb={'3'}>
<FormLabel htmlFor='displayName'>Display Name</FormLabel>
<Input
id='displayName'
bg={bg}
_placeholder={{ color: 'gray.500' }}
border="1px"
borderColor="gray.500"
rounded="md"
placeholder='Display Name'
{...register('displayName', { required: 'Display Name is required' })}
/>
{errors.displayName && <FormErrorMessage>{ errors.displayName.message?.toString()}</FormErrorMessage>}
</FormControl>
<FormControl isInvalid={Boolean(errors.email)} mb={'3'}>
<FormLabel htmlFor='email'>Email</FormLabel>
<Input
id='email'
bg={bg}
_placeholder={{ color: 'gray.500' }}
border="1px"
borderColor="gray.500"
rounded="md"
placeholder='Email'
{...register('email', { required: 'Email is required' })}
/>
{errors.email && <FormErrorMessage>{ errors.email.message?.toString()}</FormErrorMessage>}
</FormControl>
<FormControl isInvalid={Boolean(errors.password)} mb={'3'}>
<FormLabel htmlFor='password'>Password</FormLabel>
<Input
id='password'
bg={bg}
_placeholder={{ color: 'gray.500' }}
border="1px"
borderColor="gray.500"
rounded="md"
placeholder='Password'
type='password'
{...register('password', {
required: 'You must specify a password',
minLength: {
value: 8,
message: 'Password must have at least 8 characters',
},
})}
/>
{errors.password && <FormErrorMessage>{ errors.password.message?.toString()}</FormErrorMessage>}
</FormControl>
{errors.password && (
<>
<Alert status='error' style={{ marginBottom: 10 }}>
<AlertIcon />
<AlertDescription>{errors.password.message?.toString()}</AlertDescription>
{/*<CloseButton position='absolute' right='8px' top='8px' />*/}
</Alert>
<br />
</>
)}
<FormControl isInvalid={Boolean(errors.repeat_password)} mb={'3'}>
<FormLabel htmlFor='repeat_password'>Repeat Password</FormLabel>
<Input
id='repeat_password'
bg={bg}
_placeholder={{ color: 'gray.500' }}
border="1px"
borderColor="gray.500"
rounded="md"
placeholder='Repeat Password'
type='password'
{...register('repeat_password', {
validate: (value) =>
value === password.current || 'The passwords do not match',
})}
/>
{errors.repeat_password && <FormErrorMessage>{ errors.repeat_password.message?.toString()}</FormErrorMessage>}
</FormControl>
{errors.repeat_password && (
<>
<Alert status='error' style={{ marginBottom: 10 }}>
<AlertIcon />
<AlertDescription>{errors.repeat_password.message?.toString()}</AlertDescription>
{/*<CloseButton position='absolute' right='8px' top='8px' />*/}
</Alert>
</>
)}
<br />
<Button
type='submit'
disabled={!isDirty}
isLoading={isSubmitting}
>
Submit
</Button>
</form>
</Container>
)
}

View File

@ -0,0 +1,178 @@
import React, { useEffect } from 'react'
import { Controller, useForm } from 'react-hook-form'
import { IForgotPassword, UserPreferences, UserUpdateValues } from '../../models/user'
import { loadedPreferences, loggedInUser, updateUserPreferences } from '../../stateManagement/userState'
import {
Button,
Container, Flex,
FormControl,
FormLabel,
Heading,
Input,
Stack, Switch,
useColorModeValue,
useToast,
} from '@chakra-ui/react'
import { useHookstate } from '@hookstate/core'
import agent from '../../api/agent'
import { useHistory } from 'react-router-dom'
export default function EditUser() {
const userState = useHookstate(loggedInUser)
const user = userState.get()
const passwordLoading = useHookstate(false)
const passwordDisabled = useHookstate(false)
const preferencesState = useHookstate(loadedPreferences)
const preferences = preferencesState.get()
const {
register,
control,
handleSubmit,
reset,
setValue,
formState: { isSubmitting, isDirty },
} = useForm({
defaultValues: {
name: user.name,
displayName: user.displayName,
email: user.email,
theme: preferences.theme === "dark",
stickyNav: preferences.stickyNav
},
})
const history = useHistory()
useEffect(() => {
reset(user)
return
}, [user.name !== ''])
const toast = useToast()
interface Message {
success: boolean
data: string
}
const onSubmit = handleSubmit(async (data) => {
let theme: "light" | "dark"
data.theme ? theme = "dark" : theme = "light"
const newUserData: UserUpdateValues = {...data, role: user.role, avatar: user.avatar}
const newPreferenceData: UserPreferences = { id: preferences.id, theme, stickyNav: data.stickyNav }
await agent.Account.update(newUserData)
await updateUserPreferences(newPreferenceData)
return history.push('/edit-user')
})
const engageChangePassword = async (values: IForgotPassword) => {
const message = await agent.Account.forgot(values as IForgotPassword) as Message
toast({
title: 'Email Sent',
status: 'success',
description: message.data,
isClosable: true,
duration: 10000,
position: 'top'
})
}
useEffect(() => {
setValue("theme", preferences.theme === "dark")
}, [setValue, preferences.theme]);
useEffect(() => {
setValue("stickyNav", preferences.stickyNav)
}, [setValue, preferences.stickyNav])
const bg = useColorModeValue('gray.50', 'transparent')
return (
<Container style={{ marginTop: '7rem', paddingBottom: '5rem' }}>
<Heading as="h1" marginBottom={'3rem'}>Edit {user.displayName}</Heading>
<form onSubmit={onSubmit}>
<FormControl>
<FormLabel htmlFor="name">Full Name</FormLabel>
<Input
id="name"
bg={bg}
border="1px"
borderColor="gray.500"
rounded="md"
{...register('name', { required: 'Name is required' })}
/>
</FormControl>
<FormControl mt={'3'}>
<FormLabel htmlFor="displayName">Display Name</FormLabel>
<Input
id="displayName"
bg={bg}
border="1px"
borderColor="gray.500"
rounded="md"
{...register('displayName', { required: 'Display Name is required' })}
/>
</FormControl>
<FormControl mt={'3'}>
<FormLabel htmlFor="email">Email</FormLabel>
<Input
id="email"
bg={bg}
_placeholder={{ color: 'gray.500' }}
isDisabled
border="1px"
borderColor="gray.500"
rounded="md"
placeholder="Email"
{...register('email', { required: 'Email is required' })}
/>
</FormControl>
<FormControl>
<Flex mt={'6'}>
<FormLabel htmlFor={"theme"}>Dark Mode:</FormLabel>
<Controller
control={control}
name="theme"
render={({ field: { onChange, value, ref } }) => (
<Switch isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</Flex>
</FormControl>
<FormControl>
<Flex mt={'6'}>
<FormLabel htmlFor={"stickyNav"}>Sticky Navbar:</FormLabel>
<Controller
control={control}
name="stickyNav"
render={({ field: { onChange, value, ref } }) => (
<Switch isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</Flex>
</FormControl>
<br />
<Stack spacing={[1, 5]} direction={'row'} align="self-end" pb="3">
<Button
type="submit"
disabled={!isDirty}
isLoading={isSubmitting}
>
Submit
</Button>
<Button
isLoading={passwordLoading.get()}
isDisabled={passwordDisabled.get()}
onMouseDown={async () => {
passwordLoading.set(true)
passwordDisabled.set(true)
await engageChangePassword({ email: user.email })
passwordLoading.set(false)
}}
>
Change Password
</Button>
</Stack>
</form>
</Container>
)
}

View File

@ -0,0 +1,74 @@
import React from 'react'
import {
Box,
Button,
Container,
Grid,
GridItem,
Heading,
Image,
Table,
TableContainer,
Tbody,
Td,
Text,
Tr,
useColorModeValue
} from '@chakra-ui/react'
import {useHookstate} from '@hookstate/core'
import {loggedInUser} from '../../stateManagement/userState'
import {Link} from 'react-router-dom'
const UserProfile = () => {
const user = useHookstate(loggedInUser).get()
const bg = useColorModeValue('white', 'gray.700')
return (
<Container maxW={'5xl'} my={'5rem'} centerContent>
<Heading mb={'1rem'} as="h1">User Profile</Heading>
<Grid templateColumns={`repeat(7, 1fr)`} templateRows={'2'} gap={6}>
<GridItem rowStart={1} rowEnd={1} colStart={7} alignSelf={'end'} justifySelf={'end'}>
<Button as={Link} to={`/edit-user`} colorScheme="blue" style={{textAlign: 'right'}}>
<Text mt={'1'}>
Edit
</Text>
</Button>
</GridItem>
<GridItem rowStart={2} rowEnd={2} colSpan={2} colStart={2}>
<Box display={'flex'} alignItems={'center'}>
<Image bg={bg} p="5" rounded="md" objectFit={'cover'} src={user.avatar}
alt={`${user.displayName} Avatar`}/>
</Box>
</GridItem>
<GridItem rowStart={2} rowEnd={2} colStart={4} colEnd={8}>
<Box bg={bg} p="5" rounded="md">
<TableContainer>
<Table variant="simple">
<Tbody>
<Tr>
<Td><Text fontSize={'1.5rem'}>Display Name: </Text></Td>
<Td><Text fontSize={'1.5rem'}>{user.displayName}</Text></Td>
</Tr>
<Tr>
<Td><Text fontSize={'1.5rem'}>Full Name: </Text></Td>
<Td><Text fontSize={'1.5rem'}>{user.name}</Text></Td>
</Tr>
<Tr>
<Td><Text fontSize={'1.5rem'}>Email: </Text></Td>
<Td><Text fontSize={'1.5rem'}>{user.email}</Text></Td>
</Tr>
<Tr>
<Td><Text fontSize={'1.5rem'}>Role: </Text></Td>
<Td><Text fontSize={'1.5rem'}>{user.role}</Text></Td>
</Tr>
</Tbody>
</Table>
</TableContainer>
</Box>
</GridItem>
</Grid>
</Container>
)
}
export default UserProfile

40
src/errors/NotFound.tsx Normal file
View File

@ -0,0 +1,40 @@
import React, {lazy, Suspense} from 'react'
import {Link} from 'react-router-dom'
import {Button, Heading} from '@chakra-ui/react'
import LoadingModal from "../components/LoadingModal";
const styles = {
retroFontHeader: {
fontFamily: ['RetroGaming', 'sans-serif'].join(','),
fontSize: '4.3rem',
marginLeft: '5rem',
marginTop: '5rem'
},
retroFontBody: {
fontFamily: ['RetroGaming', 'sans-serif'].join(','),
},
}
export default function NotFound() {
const Video = lazy(() => import("./Video"))
return (
<>
<Heading as="h1" style={styles.retroFontHeader}>
404 :(
</Heading>
<Heading
as="h3"
style={{fontSize: '2.5rem', marginBottom: '2rem', marginLeft: '5rem'}}
>
It looks like you hit a{' '}
{<span style={styles.retroFontBody}>DEAD</span>} end!
</Heading>
<Button colorScheme='blue' as={Link} to={'/games'} style={{fontSize: '1.2rem', marginLeft: '5rem'}}>
Back to Games List
</Button>
<Suspense fallback={<LoadingModal />}>
<Video/>
</Suspense>
</>
)
}

View File

@ -0,0 +1,42 @@
import React, {lazy, Suspense} from 'react'
import {Link} from 'react-router-dom'
import {Button, Heading} from '@chakra-ui/react'
import LoadingModal from "../components/LoadingModal";
const styles = {
retroFontHeader: {
fontFamily: ['RetroGaming', 'sans-serif'].join(','),
fontSize: '4.3rem',
marginLeft: '5rem',
marginTop: '5rem'
},
retroFontBody: {
fontFamily: ['RetroGaming', 'sans-serif'].join(','),
},
}
export default function SomethingWentWrong() {
const Video = lazy(() => import("./Video"))
return (
<>
<Heading as="h1" style={styles.retroFontHeader}>
Something Went Wrong
</Heading>
<Heading
as="h3"
style={{fontSize: '2.5rem', marginBottom: '2rem', marginLeft: '5rem'}}
>
{<span style={styles.retroFontBody}>WE APOLOGIZE!</span>}<br/>
Please contact the website administrator!
</Heading>
<Button colorScheme='blue' as={Link} to={'/games'} style={{fontSize: '1.2rem', marginLeft: '5rem'}}>
<div style={{marginTop: '.25rem'}}>
Back to Games List
</div>
</Button>
<Suspense fallback={<LoadingModal/>}>
<Video/>
</Suspense>
</>
)
}

61
src/errors/TestError.tsx Normal file
View File

@ -0,0 +1,61 @@
import React from 'react'
import axios from 'axios'
import { Container, Button, Heading } from '@chakra-ui/react'
export default function TestErrors() {
const baseUrl = 'http://localhost:5000/api/'
function handleNotFound() {
axios
.get(baseUrl + 'buggy/not-found')
.catch((err) => console.error(err.response))
}
function handleBadRequest() {
axios
.get(baseUrl + 'buggy/bad-request')
.catch((err) => console.error(err.response))
}
function handleServerError() {
axios
.get(baseUrl + 'buggy/server-error')
.catch((err) => console.error(err.response))
}
function handleUnauthorised() {
axios
.get(baseUrl + 'buggy/unauthorised')
.catch((err) => console.error(err.response))
}
function handleBadGuid() {
axios
.get(baseUrl + 'activities/notaguid')
.catch((err) => console.error(err.response))
}
function handleValidationError() {
axios
.post(baseUrl + 'activities', {})
.catch((err) => console.error(err.reponse))
}
return (
<>
<Container>
<Heading as='h1'>Test Error component</Heading>
<Container>
<div>
<Button onMouseDown={handleNotFound}>Not Found</Button>
<Button onMouseDown={handleBadRequest}>Bad Request</Button>
<Button onMouseDown={handleValidationError}>Validation Error</Button>
<Button onMouseDown={handleServerError}>Server Error</Button>
<Button onMouseDown={handleUnauthorised}>Unauthorised</Button>
<Button onMouseDown={handleBadGuid}>Bad Guid</Button>
</div>
</Container>
</Container>
</>
)
}

42
src/errors/Video.tsx Normal file
View File

@ -0,0 +1,42 @@
import React from "react";
import game_over_mp4 from "../resources/video/game_over.mp4";
import game_over_webm from "../resources/video/game_over.webm";
import game_over_desktop from "../resources/images/game_over.jpg";
import game_over_mobile from "../resources/images/game_over_mobile.jpg";
export default function Video() {
return (
<div>
{window.innerWidth >= 895 ? (
<video
className='videoBackground'
poster={game_over_desktop}
autoPlay
playsInline
muted
loop
preload="auto"
>
<source src={game_over_mp4} type="video/mp4" />
<source src={game_over_webm} type="video/webm" />
Your browser does not support the video tag!
</video>
) : (
<video
className='videoBackground'
poster={game_over_mobile}
playsInline
muted
loop
preload="auto"
>
<source src={game_over_mp4} type="video/mp4" />
<source src={game_over_webm} type="video/webm" />
Your browser does not support the video tag!
</video>
)}
</div>
)
};

35
src/index.tsx Normal file
View File

@ -0,0 +1,35 @@
import "@hookstate/devtools";
import React from "react";
import {createRoot} from "react-dom/client";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import "react-toastify/dist/ReactToastify.min.css";
import "./styles/typography.css";
import "./styles/videoBackground.css";
import "./styles/overrides.css";
import "react-responsive-carousel/lib/styles/carousel.min.css";
import {Router} from "react-router-dom";
import {createBrowserHistory} from "history";
import {ChakraProvider, ColorModeScript} from "@chakra-ui/react";
import theme from "./styles/theme";
export const history = createBrowserHistory();
const container = document.getElementById("root");
const root = createRoot(container!);
root.render(
<React.StrictMode>
<Router history={history}>
<ChakraProvider theme={theme}>
<ColorModeScript initialColorMode={theme.config.initialColorMode}/>
<App/>
</ChakraProvider>
</Router>
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

54
src/models/defaultGame.ts Normal file
View File

@ -0,0 +1,54 @@
const defaultGame = {
_id: '',
title: '',
series: '',
steamId: '',
slug: '',
frontImage: '',
screenshots: [],
genre: [],
os: {
windows: true,
mac: false,
linux: false,
android: false,
ios: false
},
wine: 'Not Tested',
controller: 'No Controller Support',
developer: [],
publisher: [],
releaseDate: '',
shortDesc: '',
reviews: '',
summary: '',
intel: 'Not Tested',
steamRating: 0,
systemRequirements: {
windows: {
minimum: '',
recommended: '',
},
mac: {
minimum: '',
recommended: '',
},
linux: {
minimum: '',
recommended: '',
}
},
accessedBy: [{
user: '',
store: ['Steam'],
playStatus: 'Never Played',
soundtrack: 'No',
rating: 0,
}],
createDate: '',
lastUpdateDate: '',
lastModifiedBy: '',
scrape: true,
}
export default defaultGame

11
src/models/defaultUser.ts Normal file
View File

@ -0,0 +1,11 @@
const defaultUser = {
_id: '',
name: '',
displayName: '',
email: '',
avatar: '',
token: '',
role: '',
}
export default defaultUser

100
src/models/game.ts Normal file
View File

@ -0,0 +1,100 @@
export interface GameList {
success: boolean
searchParams: string
count: Count
pagination: Pagination
filters: Filters
data: Data[]
}
export default interface Game {
success: boolean
data: Data
}
export interface Data {
_id: string
title: string
series: string
steamId: string
slug: string
frontImage: string
screenshots: { id: number; full: string; thumbnail: string }[]
genre: string[]
os: Os
wine: string
controller: string
developer: string[]
publisher: string[]
releaseDate: string
shortDesc: string
reviews: string
summary: string
intel: string
steamRating: number
systemRequirements: SystemRequirements
accessedBy: AccessedBy[]
createDate: string
lastUpdateDate: string
lastModifiedBy: string
scrape: boolean
}
export interface Os {
windows: boolean
mac: boolean
linux: boolean
android: boolean
ios: boolean
}
export interface SystemRequirements {
windows: WindowsOrMacOrLinux
mac: WindowsOrMacOrLinux
linux: WindowsOrMacOrLinux
}
export interface WindowsOrMacOrLinux {
minimum: string
recommended: string
}
export interface AccessedBy {
user: string
store: string[]
playStatus: string
soundtrack: string
rating: number
}
export interface Pagination {
prev: PrevNext
next: PrevNext
}
export interface PrevNext {
page: number
}
export interface Count {
gamesPerPage: number
totalGames: number
currentPage: number
pages: number
limit: number
}
export interface Filters {
series: string
playStatus: string
genre: string
os: string
store: string
developer: string
publisher: string
controller: string
soundtrack: string
intel: string
wine: string
rating: string
steamRating: string
}

378
src/models/tags.ts Normal file
View File

@ -0,0 +1,378 @@
export const genreTags = [
{ value: "1980s", label: "1980s" },
{ value: "1990's", label: "1990's" },
{ value: "2.5D", label: "2.5D" },
{ value: "2D", label: "2D" },
{ value: "2D Fighter", label: "2D Fighter" },
{ value: "2D Platformer", label: "2D Platformer" },
{ value: "360 Video", label: "360 Video" },
{ value: "3D", label: "3D" },
{ value: "3D Fighter", label: "3D Fighter" },
{ value: "3D Platformer", label: "3D Platformer" },
{ value: "3D Vision", label: "3D Vision" },
{ value: "4 Player Local", label: "4 Player Local" },
{ value: "4X", label: "4X" },
{ value: "6DOF", label: "6DOF" },
{ value: "Abstract", label: "Abstract" },
{ value: "Action", label: "Action" },
{ value: "Action Roguelike", label: "Action Roguelike" },
{ value: "Action RPG", label: "Action RPG" },
{ value: "Action-Adventure", label: "Action-Adventure" },
{ value: "Addictive", label: "Addictive" },
{ value: "Adventure", label: "Adventure" },
{ value: "Agriculture", label: "Agriculture" },
{ value: "Aliens", label: "Aliens" },
{ value: "Alternate History", label: "Alternate History" },
{ value: "America", label: "America" },
{ value: "Anime", label: "Anime" },
{ value: "Arcade", label: "Arcade" },
{ value: "Archery", label: "Archery" },
{ value: "Arena Shooter", label: "Arena Shooter" },
{ value: "Artificial Intelligence", label: "Artificial Intelligence" },
{ value: "Assassin", label: "Assassin" },
{ value: "Asymmetric VR", label: "Asymmetric VR" },
{ value: "Asynchronous Multiplayer", label: "Asynchronous Multiplayer" },
{ value: "Atmospheric", label: "Atmospheric" },
{ value: "ATV", label: "ATV" },
{ value: "Auto Battler", label: "Auto Battler" },
{ value: "Automation", label: "Automation" },
{ value: "Automobile Sim", label: "Automobile Sim" },
{ value: "Base Building", label: "Base Building" },
{ value: "Baseball", label: "Baseball" },
{ value: "Based on a Novel", label: "Based on a Novel" },
{ value: "Basketball", label: "Basketball" },
{ value: "Batman", label: "Batman" },
{ value: "Battle Royale", label: "Battle Royale" },
{ value: "Beat 'em up", label: "Beat 'em up" },
{ value: "Beautiful", label: "Beautiful" },
{ value: "Bikes", label: "Bikes" },
{ value: "BMX", label: "BMX" },
{ value: "Board Game", label: "Board Game" },
{ value: "Bowling", label: "Bowling" },
{ value: "Boxing", label: "Boxing" },
{ value: "Building", label: "Building" },
{ value: "Bullet Hell", label: "Bullet Hell" },
{ value: "Bullet Time", label: "Bullet Time" },
{ value: "Capitalism", label: "Capitalism" },
{ value: "Card Battler", label: "Card Battler" },
{ value: "Card Game", label: "Card Game" },
{ value: "Cartoon", label: "Cartoon" },
{ value: "Cartoony", label: "Cartoony" },
{ value: "Casual", label: "Casual" },
{ value: "Cats", label: "Cats" },
{ value: "Character Action Game", label: "Character Action Game" },
{ value: "Character Customization", label: "Character Customization" },
{ value: "Chess", label: "Chess" },
{ value: "Choices Matter", label: "Choices Matter" },
{ value: "Choose Your Own Adventure", label: "Choose Your Own Adventure" },
{ value: "Cinematic", label: "Cinematic" },
{ value: "City Builder", label: "City Builder" },
{ value: "Class-Based", label: "Class-Based" },
{ value: "Classic", label: "Classic" },
{ value: "Clicker", label: "Clicker" },
{ value: "Co-op", label: "Co-op" },
{ value: "Co-op Campaign", label: "Co-op Campaign" },
{ value: "Cold War", label: "Cold War" },
{ value: "Collectathon", label: "Collectathon" },
{ value: "Colony Sim", label: "Colony Sim" },
{ value: "Colorful", label: "Colorful" },
{ value: "Combat", label: "Combat" },
{ value: "Combat Racing", label: "Combat Racing" },
{ value: "Comedy", label: "Comedy" },
{ value: "Comic Book", label: "Comic Book" },
{ value: "Competitive", label: "Competitive" },
{ value: "Conspiracy", label: "Conspiracy" },
{ value: "Conversation", label: "Conversation" },
{ value: "Crafting", label: "Crafting" },
{ value: "Crime", label: "Crime" },
{ value: "CRPG", label: "CRPG" },
{ value: "Cult Classic", label: "Cult Classic" },
{ value: "Cute", label: "Cute" },
{ value: "Cyberpunk", label: "Cyberpunk" },
{ value: "Cycling", label: "Cycling" },
{ value: "Dark", label: "Dark" },
{ value: "Dark Comedy", label: "Dark Comedy" },
{ value: "Dark Fantasy", label: "Dark Fantasy" },
{ value: "Dating Sim", label: "Dating Sim" },
{ value: "Deckbuilding", label: "Deckbuilding" },
{ value: "Demons", label: "Demons" },
{ value: "Destruction", label: "Destruction" },
{ value: "Detective", label: "Detective" },
{ value: "Difficult", label: "Difficult" },
{ value: "Dinosaurs", label: "Dinosaurs" },
{ value: "Diplomacy", label: "Diplomacy" },
{ value: "Documentary", label: "Documentary" },
{ value: "Dog", label: "Dog" },
{ value: "Dragons", label: "Dragons" },
{ value: "Drama", label: "Drama" },
{ value: "Driving", label: "Driving" },
{ value: "Dungeon Crawler", label: "Dungeon Crawler" },
{ value: "Dungeons & Dragons", label: "Dungeons & Dragons" },
{ value: "Dynamic Narration", label: "Dynamic Narration" },
{ value: "Economy", label: "Economy" },
{ value: "Education", label: "Education" },
{ value: "Emotional", label: "Emotional" },
{ value: "Epic", label: "Epic" },
{ value: "Episodic", label: "Episodic" },
{ value: "eSports", label: "eSports" },
{ value: "Experience", label: "Experience" },
{ value: "Experimental", label: "Experimental" },
{ value: "Exploration", label: "Exploration" },
{ value: "Faith", label: "Faith" },
{ value: "Family Friendly", label: "Family Friendly" },
{ value: "Fantasy", label: "Fantasy" },
{ value: "Farming Sim", label: "Farming Sim" },
{ value: "Fast-Paced", label: "Fast-Paced" },
{ value: "Feature Film", label: "Feature Film" },
{ value: "Female Protagonist", label: "Female Protagonist" },
{ value: "Fighting", label: "Fighting" },
{ value: "First-Person", label: "First-Person" },
{ value: "Fishing", label: "Fishing" },
{ value: "Flight", label: "Flight" },
{ value: "FMV", label: "FMV" },
{ value: "Football", label: "Football" },
{ value: "Foreign", label: "Foreign" },
{ value: "FPS", label: "FPS" },
{ value: "Funny", label: "Funny" },
{ value: "Futuristic", label: "Futuristic" },
{ value: "Gambling", label: "Gambling" },
{ value: "Game Development", label: "Game Development" },
{ value: "Games Workshop", label: "Games Workshop" },
{ value: "God Game", label: "God Game" },
{ value: "Golf", label: "Golf" },
{ value: "Gothic", label: "Gothic" },
{ value: "Grand Strategy", label: "Grand Strategy" },
{ value: "Great Soundtrack", label: "Great Soundtrack" },
{ value: "Grid-Based Movement", label: "Grid-Based Movement" },
{ value: "Gun Customization", label: "Gun Customization" },
{ value: "Hack and Slash", label: "Hack and Slash" },
{ value: "Hacking", label: "Hacking" },
{ value: "Hand-drawn", label: "Hand-drawn" },
{ value: "Heist", label: "Heist" },
{ value: "Hero Shooter", label: "Hero Shooter" },
{ value: "Hex Grid", label: "Hex Grid" },
{ value: "Hidden Object", label: "Hidden Object" },
{ value: "Historical", label: "Historical" },
{ value: "Hockey", label: "Hockey" },
{ value: "Horror", label: "Horror" },
{ value: "Horses", label: "Horses" },
{ value: "Hunting", label: "Hunting" },
{ value: "Idler", label: "Idler" },
{ value: "Illuminati", label: "Illuminati" },
{ value: "Immersive Sim", label: "Immersive Sim" },
{ value: "Indie", label: "Indie" },
{
value: "Intentionally Awkward Controls ",
label: "Intentionally Awkward Controls ",
},
{ value: "Interactive Fiction", label: "Interactive Fiction" },
{ value: "Inventory Management", label: "Inventory Management" },
{ value: "Investigation", label: "Investigation" },
{ value: "Isometric", label: "Isometric" },
{ value: "Jet", label: "Jet" },
{ value: "JRPG", label: "JRPG" },
{ value: "Lara Croft", label: "Lara Croft" },
{ value: "LEGO", label: "LEGO" },
{ value: "Lemmings", label: "Lemmings" },
{ value: "Level Editor", label: "Level Editor" },
{ value: "LGBTQ+", label: "LGBTQ+" },
{ value: "Life Sim", label: "Life Sim" },
{ value: "Linear", label: "Linear" },
{ value: "Local Co-Op", label: "Local Co-Op" },
{ value: "Local Multiplayer", label: "Local Multiplayer" },
{ value: "Logic", label: "Logic" },
{ value: "Loot", label: "Loot" },
{ value: "Looter Shooter", label: "Looter Shooter" },
{ value: "Lore-Rich", label: "Lore-Rich" },
{ value: "Lovecraftian", label: "Lovecraftian" },
{ value: "Magic", label: "Magic" },
{ value: "Management", label: "Management" },
{ value: "Mars", label: "Mars" },
{ value: "Martial Arts", label: "Martial Arts" },
{ value: "Massively Multiplayer", label: "Massively Multiplayer" },
{ value: "Masterpiece", label: "Masterpiece" },
{ value: "Match 3", label: "Match 3" },
{ value: "Mechs", label: "Mechs" },
{ value: "Medical Sim", label: "Medical Sim" },
{ value: "Medieval", label: "Medieval" },
{ value: "Memes", label: "Memes" },
{ value: "Metroidvania", label: "Metroidvania" },
{ value: "Military", label: "Military" },
{ value: "Mini Golf", label: "Mini Golf" },
{ value: "Minigames", label: "Minigames" },
{ value: "Minimalist", label: "Minimalist" },
{ value: "Mining", label: "Mining" },
{ value: "MMORPG", label: "MMORPG" },
{ value: "MOBA", label: "MOBA" },
{ value: "Mod", label: "Mod" },
{ value: "Moddable", label: "Moddable" },
{ value: "Modern", label: "Modern" },
{ value: "Motocross", label: "Motocross" },
{ value: "Motorbike", label: "Motorbike" },
{ value: "Movie", label: "Movie" },
{ value: "Multiplayer", label: "Multiplayer" },
{ value: "Multiple Endings", label: "Multiple Endings" },
{ value: "Music", label: "Music" },
{
value: "Music-Based Procedural Generation",
label: "Music-Based Procedural Generation",
},
{ value: "Mystery", label: "Mystery" },
{ value: "Mystery Dungeon", label: "Mystery Dungeon" },
{ value: "Mythology", label: "Mythology" },
{ value: "Narration", label: "Narration" },
{ value: "Nature", label: "Nature" },
{ value: "Naval", label: "Naval" },
{ value: "Naval Combat", label: "Naval Combat" },
{ value: "Ninja", label: "Ninja" },
{ value: "Noir", label: "Noir" },
{ value: "Nonlinear", label: "Nonlinear" },
{ value: "Offroad", label: "Offroad" },
{ value: "Old School", label: "Old School" },
{ value: "On-Rails Shooter", label: "On-Rails Shooter" },
{ value: "Online Co-Op", label: "Online Co-Op" },
{ value: "Open World", label: "Open World" },
{ value: "Open World Survival Craft", label: "Open World Survival Craft" },
{ value: "Otome", label: "Otome" },
{ value: "Outbreak Sim", label: "Outbreak Sim" },
{ value: "Parkour", label: "Parkour" },
{ value: "Party-Based RPG", label: "Party-Based RPG" },
{ value: "Perma Death", label: "Perma Death" },
{ value: "Philosophical", label: "Philosophical" },
{ value: "Physics", label: "Physics" },
{ value: "Pinball", label: "Pinball" },
{ value: "Pirates", label: "Pirates" },
{ value: "Pixel Graphics", label: "Pixel Graphics" },
{ value: "Platformer", label: "Platformer" },
{ value: "Point & Click", label: "Point & Click" },
{ value: "Political", label: "Political" },
{ value: "Political Sim", label: "Political Sim" },
{ value: "Politics", label: "Politics" },
{ value: "Pool", label: "Pool" },
{ value: "Post-apocalyptic", label: "Post-apocalyptic" },
{ value: "Precision Platformer", label: "Precision Platformer" },
{ value: "Procedural Generation", label: "Procedural Generation" },
{ value: "Programming", label: "Programming" },
{ value: "Psychedelic", label: "Psychedelic" },
{ value: "Psychological", label: "Psychological" },
{ value: "Puzzle", label: "Puzzle" },
{ value: "PvE", label: "PvE" },
{ value: "PvP", label: "PvP" },
{ value: "Quick-Time Events", label: "Quick-Time Events" },
{ value: "Racing", label: "Racing" },
{ value: "Real Time Tactics", label: "Real Time Tactics" },
{ value: "Real-Time", label: "Real-Time" },
{ value: "Real-Time with Pause", label: "Real-Time with Pause" },
{ value: "Realistic", label: "Realistic" },
{ value: "Relaxing", label: "Relaxing" },
{ value: "Remake", label: "Remake" },
{ value: "Replay Value", label: "Replay Value" },
{ value: "Resource Management", label: "Resource Management" },
{ value: "Retro", label: "Retro" },
{ value: "Rhythm", label: "Rhythm" },
{ value: "Robots", label: "Robots" },
{ value: "Roguelike", label: "Roguelike" },
{ value: "Roguelite", label: "Roguelite" },
{ value: "Roguevania", label: "Roguevania" },
{ value: "Romance", label: "Romance" },
{ value: "Rome", label: "Rome" },
{ value: "RPG", label: "RPG" },
{ value: "RTS", label: "RTS" },
{ value: "Runner", label: "Runner" },
{ value: "Sailing", label: "Sailing" },
{ value: "Sandbox", label: "Sandbox" },
{ value: "Satire", label: "Satire" },
{ value: "Sci-fi", label: "Sci-fi" },
{ value: "Science", label: "Science" },
{ value: "Score Attack", label: "Score Attack" },
{ value: "Sequel", label: "Sequel" },
{ value: "Shoot 'Em Up", label: "Shoot 'Em Up" },
{ value: "Shooter", label: "Shooter" },
{ value: "Short", label: "Short" },
{ value: "Side Scroller", label: "Side Scroller" },
{ value: "Silent Protagonist", label: "Silent Protagonist" },
{ value: "Simulation", label: "Simulation" },
{ value: "Singleplayer", label: "Singleplayer" },
{ value: "Skateboarding", label: "Skateboarding" },
{ value: "Skating", label: "Skating" },
{ value: "Skiing", label: "Skiing" },
{ value: "Sniper", label: "Sniper" },
{ value: "Snow", label: "Snow" },
{ value: "Snowboarding", label: "Snowboarding" },
{ value: "Soccer", label: "Soccer" },
{ value: "Sokoban", label: "Sokoban" },
{ value: "Solitaire", label: "Solitaire" },
{ value: "Souls-like", label: "Souls-like" },
{ value: "Soundtrack", label: "Soundtrack" },
{ value: "Space", label: "Space" },
{ value: "Space Sim", label: "Space Sim" },
{ value: "Spectacle fighter", label: "Spectacle fighter" },
{ value: "Spelling", label: "Spelling" },
{ value: "Split Screen", label: "Split Screen" },
{ value: "Sports", label: "Sports" },
{ value: "Star Wars", label: "Star Wars" },
{ value: "Stealth", label: "Stealth" },
{ value: "Steampunk", label: "Steampunk" },
{ value: "Story Rich", label: "Story Rich" },
{ value: "Strategy", label: "Strategy" },
{ value: "Strategy RPG", label: "Strategy RPG" },
{ value: "Stylized", label: "Stylized" },
{ value: "Submarine", label: "Submarine" },
{ value: "Superhero", label: "Superhero" },
{ value: "Supernatural", label: "Supernatural" },
{ value: "Surreal", label: "Surreal" },
{ value: "Survival", label: "Survival" },
{ value: "Survival Horror", label: "Survival Horror" },
{ value: "Swordplay", label: "Swordplay" },
{ value: "Tabletop", label: "Tabletop" },
{ value: "Tactical", label: "Tactical" },
{ value: "Tactical RPG", label: "Tactical RPG" },
{ value: "Tanks", label: "Tanks" },
{ value: "Team-Based", label: "Team-Based" },
{ value: "Tennis", label: "Tennis" },
{ value: "Text-Based", label: "Text-Based" },
{ value: "Third Person", label: "Third Person" },
{ value: "Third-Person Shooter", label: "Third-Person Shooter" },
{ value: "Thriller", label: "Thriller" },
{ value: "Time Attack", label: "Time Attack" },
{ value: "Time Management", label: "Time Management" },
{ value: "Time Manipulation", label: "Time Manipulation" },
{ value: "Time Travel", label: "Time Travel" },
{ value: "Top-Down", label: "Top-Down" },
{ value: "Top-Down Shooter", label: "Top-Down Shooter" },
{ value: "Tower Defense", label: "Tower Defense" },
{ value: "Trading", label: "Trading" },
{ value: "Trading Card Game", label: "Trading Card Game" },
{ value: "Traditional Roguelike", label: "Traditional Roguelike" },
{ value: "Trains", label: "Trains" },
{ value: "Transhumanism", label: "Transhumanism" },
{ value: "Transportation", label: "Transportation" },
{ value: "Trivia", label: "Trivia" },
{ value: "Turn-Based", label: "Turn-Based" },
{ value: "Turn-Based Combat", label: "Turn-Based Combat" },
{ value: "Turn-Based Strategy", label: "Turn-Based Strategy" },
{ value: "Turn-Based Tactics", label: "Turn-Based Tactics" },
{ value: "Tutorial", label: "Tutorial" },
{ value: "Twin Stick Shooter", label: "Twin Stick Shooter" },
{ value: "Typing", label: "Typing" },
{ value: "Underground", label: "Underground" },
{ value: "Underwater", label: "Underwater" },
{ value: "Unforgiving", label: "Unforgiving" },
{ value: "Vampire", label: "Vampire" },
{ value: "Vehicular Combat", label: "Vehicular Combat" },
{ value: "Villain Protagonist", label: "Villain Protagonist" },
{ value: "Visual Novel", label: "Visual Novel" },
{ value: "Voxel", label: "Voxel" },
{ value: "VR", label: "VR" },
{ value: "Walking Simulator", label: "Walking Simulator" },
{ value: "War", label: "War" },
{ value: "Wargame", label: "Wargame" },
{ value: "Warhammer 40K", label: "Warhammer 40K" },
{ value: "Werewolves", label: "Werewolves" },
{ value: "Western", label: "Western" },
{ value: "Word Game", label: "Word Game" },
{ value: "World War I", label: "World War I" },
{ value: "World War II", label: "World War II" },
{ value: "Wrestling", label: "Wrestling" },
];

44
src/models/user.ts Normal file
View File

@ -0,0 +1,44 @@
export interface UserAPI {
success: boolean
data: User
}
export default interface User {
_id: string
name: string
displayName: string
email: string
avatar: string
role: string
token: string
}
export interface UserPreferences {
id: string
theme: 'light' | 'dark'
stickyNav: boolean
}
export interface UserFormValues {
email: string
password: string
name?: string
displayName?: string
role?: string
}
export interface UserUpdateValues {
email: string
name: string
displayName: string
role: string
avatar: string
}
export interface IForgotPassword {
email: string
}
export interface IResetPassword {
password: string
}

74
src/react-app-env.d.ts vendored Normal file
View File

@ -0,0 +1,74 @@
/// <reference types="react" />
/// <reference types="react-dom" />
declare namespace NodeJS {
interface Process{
env: ProcessEnv
}
interface ProcessEnv {
/**
* By default, there are two modes in Vite:
*
* * `development` is used by vite and vite serve
* * `production` is used by vite build
*
* You can overwrite the default mode used for a command by passing the --mode option flag.
*
*/
readonly NODE_ENV: 'development' | 'production'
}
}
declare var process: NodeJS.Process
declare module '*.gif' {
const src: string
export default src
}
declare module '*.jpg' {
const src: string
export default src
}
declare module '*.jpeg' {
const src: string
export default src
}
declare module '*.png' {
const src: string
export default src
}
declare module '*.webp' {
const src: string
export default src
}
declare module '*.svg' {
import * as React from 'react'
export const ReactComponent: React.FunctionComponent<React.SVGProps<
SVGSVGElement
> & { title?: string }>
const src: string;
export default src
}
declare module '*.module.css' {
const classes: { readonly [key: string]: string }
export default classes
}
declare module '*.module.scss' {
const classes: { readonly [key: string]: string }
export default classes
}
declare module '*.module.sass' {
const classes: { readonly [key: string]: string }
export default classes
}

15
src/reportWebVitals.ts Normal file
View File

@ -0,0 +1,15 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Binary file not shown.

5
src/setupTests.ts Normal file
View File

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View File

@ -0,0 +1,37 @@
import defaultGame from '../models/defaultGame'
import Game, { Data, GameList } from '../models/game'
import { hookstate } from '@hookstate/core'
export const gameDashboardState = hookstate({
success: false,
count: {
gamesPerPage: 0,
totalGames: 0,
currentPage: 0,
pages: 0,
limit: 0,
},
searchParams: '',
pagination: { next: { page: 1 } },
filters: {
series: '',
playStatus: '',
genre: '',
os: '',
store: '',
controller: '',
developer: '',
publisher: '',
soundtrack: '',
intel: '',
wine: '',
rating: '',
steamRating: '',
},
data: [defaultGame as Data]
} as GameList)
export const detailedGameState = hookstate({
success: false,
data: defaultGame as Data
} as Game)

View File

@ -0,0 +1,15 @@
import {hookstate, SetStateAction} from '@hookstate/core'
export const loadingState = hookstate(false)
export const loadingAndSaveState = hookstate(false)
export const loadingAndContinueState = hookstate(false)
export const openAndCloseDeleteDialogue = hookstate(false)
export const openAndCloseSearchDialogue = hookstate(false)
export const showFiltersModuleState = hookstate(false)
export const searchCompletedState = hookstate(false)
export const changeLoadState = (bool: SetStateAction<boolean>) => {
loadingState.set(bool)
loadingAndContinueState.set(bool)
loadingAndSaveState.set(bool)
}

View File

@ -0,0 +1,4 @@
import { hookstate } from '@hookstate/core'
export const ratingState = hookstate('[]')
export const steamRatingState = hookstate('[]')

View File

@ -0,0 +1,30 @@
import { hookstate } from '@hookstate/core'
import { genreTags } from '../models/tags'
export const tagState = hookstate({
genres: genreTags,
series: [
{
value: '',
label: '',
},
],
store: [
{
value: '',
label: '',
},
],
developer: [
{
value: '',
label: '',
},
],
publisher: [
{
value: '',
label: '',
},
],
})

View File

@ -0,0 +1,103 @@
import defaultUser from '../models/defaultUser'
import User, {UserFormValues, UserPreferences} from '../models/user'
import {hookstate, useHookstate} from '@hookstate/core'
import {localstored} from '@hookstate/localstored'
import agent from '../api/agent'
import {history} from '..'
export const loggedInUser = hookstate(defaultUser as User)
export const userToken = hookstate('')
export const loggedInState = hookstate(false)
export const appLoadedState = hookstate(false)
export const adminMode = hookstate(false)
export const loadedPreferences = hookstate({} as UserPreferences)
export const userPreferences = hookstate([] as UserPreferences[],
localstored({
key: 'userPreferences'
}))
export const createUser = async (creds: UserFormValues) => {
try {
const user = await agent.Account.register(creds)
setToken(user.token)
await getUser(user.token)
loggedInState.set(true)
history.push('/games')
} catch (error) {
throw error
}
}
export const login = async (creds: UserFormValues) => {
try {
const user = await agent.Account.login(creds)
setToken(user.token)
await getUser(user.token)
loggedInState.set(true)
history.push('/games')
} catch (error) {
throw error
}
}
export const logout = () => {
adminMode.set(false)
setToken('')
window.localStorage.removeItem('jwt')
loggedInUser.set(defaultUser)
loggedInState.set(false)
history.push('/')
}
export const setToken = (token: string) => {
if (token.length > 1) window.localStorage.setItem('jwt', token)
userToken.set(token)
}
export const adminModeToggle = () => {
adminMode.set(toggle => !toggle)
const toggle = adminMode.get()
if (toggle) {
window.localStorage.setItem('admin', 'active')
} else {
window.localStorage.removeItem('admin')
}
}
export const getUser = async (token: string) => {
try {
userToken.set(token)
const user = await agent.Account.current()
loggedInState.set(true)
loggedInUser.set(user.data)
const userPreferencesExist = userPreferences.get().findIndex(x => x.id == user.data._id)
if(userPreferencesExist === -1) {
userPreferences.merge([{
id: user.data._id,
theme: 'light',
stickyNav: true,
}])
}
const currentPreferences = userPreferences.get().filter(x => x.id == user.data._id)
loadedPreferences.set({
id: currentPreferences[0].id,
theme: currentPreferences[0].theme,
stickyNav: currentPreferences[0].stickyNav
})
} catch (error) {
console.error(error)
}
}
export const updateUserPreferences = async (pref: UserPreferences) => {
try {
const user = await agent.Account.current()
const foundIndex = userPreferences.get().findIndex(x => x.id === user.data._id)
userPreferences[foundIndex].set(pref)
loadedPreferences.set(pref)
} catch (err) {
console.error(err)
}
}
export const setAppLoaded = (bool: boolean) => appLoadedState.set(bool)

91
src/styles/overrides.css Normal file
View File

@ -0,0 +1,91 @@
.bb_ul {
padding-left: 2rem;
}
.empty-icons {
display: flex !important;
}
.filled-icons {
/*noinspection CssInvalidPropertyValue*/
display: -webkit-inline-box !important;
}
.jodit_theme_chakra {
--jd-color-background-default: #1A202C;
--jd-color-border: #474025;
--jd-color-panel: #5fd3a2;
--jd-color-icon: #8b572a;
}
.jodit-toolbar__box:not(:empty):not(:empty) {
background: #1A202C;
}
.jodit_theme_dark .jodit-workplace + .jodit-status-bar:not(:empty) {
background: #1A202C;
}
.jodit_theme_dark .jodit-wysiwyg {
background: #1A202C;
}
.jodit_theme_dark .jodit-popup__content {
background: #1A202C;
}
.jodit_theme_dark .jodit-toolbar-button:hover:not([disabled]) {
background-color: #4A5568;
}
.jodit_theme_dark .jodit-toolbar-button__button:hover:not([disabled]) {
background-color: #4A5568;
}
.jodit-toolbar-button__button:active:not([disabled]) {
background-color: #2D3748;
}
.jodit-toolbar-button__button:focus-visible:not([disabled]) {
background-color: #718096;
}
.jodit_theme_dark .jodit-toolbar-button__trigger:hover:not([disabled]) {
background-color: #4A5568;
}
.jodit-toolbar-button__trigger:active:not([disabled]) {
background-color: #2D3748;
}
.jodit-toolbar-button__button[aria-pressed="true"]:not([disabled]) {
background-color: #718096;
}
.jodit-source {
background-color: #2D3748;
}
.ace-idle-fingers {
background-color: #2D3748;
}
.ace-idle-fingers .ace_gutter {
background-color: #2D3748;
}
.ace-idle-fingers .ace_gutter-active-line {
background-color: #4A5568;
}
.jodit-dialog_theme_dark .jodit-dialog__panel {
background-color: #4A5568;
border-radius: 5px;
}
.jodit-ui-button_variant_primary {
background-color: #2D3748;
}
.jodit-dialog_theme_dark .jodit-ui-button:hover:not([disabled]) {
background-color: #718096;
}

18
src/styles/theme.tsx Normal file
View File

@ -0,0 +1,18 @@
import { extendTheme, Theme } from '@chakra-ui/react'
import { mode } from '@chakra-ui/theme-tools'
import {loadedPreferences} from "../stateManagement/userState";
export default extendTheme({
initialColorMode: loadedPreferences.theme,
useSystemColorMode: false,
styles: {
global: (props: Theme) => ({
body: {
bg: mode('gray.200', 'gray.800')(props),
},
})
},
})

View File

@ -0,0 +1,6 @@
@font-face {
font-family: "RetroGaming";
src: url("../resources/fonts/retrogaming.ttf");
font-weight: 400;
font-style: normal;
}

View File

@ -0,0 +1,11 @@
.videoBackground {
position: absolute;
top: 0;
left: 0;
height: 100vh;
width: 100%;
z-index: -2;
overflow: hidden;
object-fit: cover;
display: block;
}

23
tsconfig.json Normal file
View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["dom", "dom.iterable", "esnext"],
"types": ["vite/client"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}

7
vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()]
})