diff --git a/astro.config.mjs b/astro.config.mjs
index 1d50a765..7e09b107 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -2,6 +2,7 @@ import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import cloudflare from '@astrojs/cloudflare';
import tailwind from '@astrojs/tailwind';
+import react from '@astrojs/react';
// https://astro.build/config
const config = defineConfig({
@@ -26,6 +27,7 @@ const config = defineConfig({
}),
output: 'hybrid',
integrations: [
+ react(),
starlight({
title: 'Contribute | freeCodeCamp.org',
description: 'Contribute to freeCodeCamp.org',
diff --git a/package.json b/package.json
index 2c95a1b1..72ea301c 100644
--- a/package.json
+++ b/package.json
@@ -28,10 +28,15 @@
"dependencies": {
"@astrojs/check": "0.9.3",
"@astrojs/cloudflare": "^11.0.5",
+ "@astrojs/react": "^3.6.2",
"@astrojs/starlight": "0.27.1",
"@astrojs/starlight-tailwind": "2.0.3",
"@astrojs/tailwind": "5.1.1",
+ "@types/react": "^18.3.10",
+ "@types/react-dom": "^18.3.0",
"astro": "4.15.9",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
"tailwindcss": "3.4.13",
"typescript": "5.6.2"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 76a27478..1de136e2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -14,6 +14,9 @@ importers:
'@astrojs/cloudflare':
specifier: ^11.0.5
version: 11.1.0(astro@4.15.9(@types/node@20.16.10)(rollup@4.21.2)(typescript@5.6.2))
+ '@astrojs/react':
+ specifier: ^3.6.2
+ version: 3.6.2(@types/react-dom@18.3.0)(@types/react@18.3.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vite@5.4.3(@types/node@20.16.10))
'@astrojs/starlight':
specifier: 0.27.1
version: 0.27.1(astro@4.15.9(@types/node@20.16.10)(rollup@4.21.2)(typescript@5.6.2))
@@ -23,9 +26,21 @@ importers:
'@astrojs/tailwind':
specifier: 5.1.1
version: 5.1.1(astro@4.15.9(@types/node@20.16.10)(rollup@4.21.2)(typescript@5.6.2))(tailwindcss@3.4.13)
+ '@types/react':
+ specifier: ^18.3.10
+ version: 18.3.10
+ '@types/react-dom':
+ specifier: ^18.3.0
+ version: 18.3.0
astro:
specifier: 4.15.9
version: 4.15.9(@types/node@20.16.10)(rollup@4.21.2)(typescript@5.6.2)
+ react:
+ specifier: ^18.3.1
+ version: 18.3.1
+ react-dom:
+ specifier: ^18.3.1
+ version: 18.3.1(react@18.3.1)
tailwindcss:
specifier: 3.4.13
version: 3.4.13
@@ -134,6 +149,15 @@ packages:
resolution: {integrity: sha512-Z9IYjuXSArkAUx3N6xj6+Bnvx8OdUSHA8YoOgyepp3+zJmtVYJIl/I18GozdJVW1p5u/CNpl3Km7/gwTJK85cw==}
engines: {node: ^18.17.1 || ^20.3.0 || >=21.0.0}
+ '@astrojs/react@3.6.2':
+ resolution: {integrity: sha512-fK29lYI7zK/KG4ZBy956x4dmauZcZ18osFkuyGa8r3gmmCQa2NZ9XNu9WaVYEUm0j89f4Gii4tbxLoyM8nk2MA==}
+ engines: {node: ^18.17.1 || ^20.3.0 || >=21.0.0}
+ peerDependencies:
+ '@types/react': ^17.0.50 || ^18.0.21
+ '@types/react-dom': ^17.0.17 || ^18.0.6
+ react: ^17.0.2 || ^18.0.0 || ^19.0.0-beta
+ react-dom: ^17.0.2 || ^18.0.0 || ^19.0.0-beta
+
'@astrojs/sitemap@3.1.6':
resolution: {integrity: sha512-1Qp2NvAzVImqA6y+LubKi1DVhve/hXXgFvB0szxiipzh7BvtuKe4oJJ9dXSqaubaTkt4nMa6dv6RCCAYeB6xaQ==}
@@ -238,6 +262,18 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
+ '@babel/plugin-transform-react-jsx-self@7.24.7':
+ resolution: {integrity: sha512-fOPQYbGSgH0HUp4UJO4sMBFjY6DuWq+2i8rixyUMb3CdGixs/gccURvYOAhajBdKDoGajFr3mUq5rH3phtkGzw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-transform-react-jsx-source@7.24.7':
+ resolution: {integrity: sha512-J2z+MWzZHVOemyLweMqngXrgGC42jQ//R0KdxqkIz/OrbVIIlhFI3WigZ5fO+nwFvBlncr4MGapd8vTyc7RPNQ==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
'@babel/plugin-transform-react-jsx@7.25.2':
resolution: {integrity: sha512-KQsqEAVBpU82NM/B/N9j9WOdphom1SZH3R+2V7INrQUH+V9EBFwZsEJl8eBIVeQE62FxJCc70jzEZwqU7RcVqA==}
engines: {node: '>=6.9.0'}
@@ -1038,6 +1074,15 @@ packages:
'@types/node@20.16.10':
resolution: {integrity: sha512-vQUKgWTjEIRFCvK6CyriPH3MZYiYlNy0fKiEYHWbcoWLEgs4opurGGKlebrTLqdSMIbXImH6XExNiIyNUv3WpA==}
+ '@types/prop-types@15.7.13':
+ resolution: {integrity: sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==}
+
+ '@types/react-dom@18.3.0':
+ resolution: {integrity: sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==}
+
+ '@types/react@18.3.10':
+ resolution: {integrity: sha512-02sAAlBnP39JgXwkAq3PeU9DVaaGpZyF3MGcC0MKgQVkZor5IiiDAipVaxQHtDJAmO4GIy/rVBy/LzVj76Cyqg==}
+
'@types/sax@1.2.7':
resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==}
@@ -1129,6 +1174,12 @@ packages:
'@ungap/structured-clone@1.2.0':
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
+ '@vitejs/plugin-react@4.3.2':
+ resolution: {integrity: sha512-hieu+o05v4glEBucTcKMK3dlES0OeJlD9YVOAPraVMOInBCwzumaIFiUjr4bHK7NPgnAHgiskUoceKercrN8vg==}
+ engines: {node: ^14.18.0 || >=16.0.0}
+ peerDependencies:
+ vite: ^4.2.0 || ^5.0.0
+
'@volar/kit@2.4.1':
resolution: {integrity: sha512-XCHjrxcvjh/GEBiJt2e1KfsP8aQ+z7ZXRKR/5BA2/SFVzM+pKpL9iHZZJN7QGMsqTOt8FgN8XQhTp8qqURn+cw==}
peerDependencies:
@@ -1466,6 +1517,9 @@ packages:
engines: {node: '>=4'}
hasBin: true
+ csstype@3.1.3:
+ resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
+
data-uri-to-buffer@2.0.2:
resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==}
@@ -2193,6 +2247,10 @@ packages:
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
+ loose-envify@1.4.0:
+ resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
+ hasBin: true
+
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -2736,6 +2794,19 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+ react-dom@18.3.1:
+ resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
+ peerDependencies:
+ react: ^18.3.1
+
+ react-refresh@0.14.2:
+ resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
+ engines: {node: '>=0.10.0'}
+
+ react@18.3.1:
+ resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
+ engines: {node: '>=0.10.0'}
+
read-cache@1.0.0:
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
@@ -2874,6 +2945,9 @@ packages:
sax@1.4.1:
resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==}
+ scheduler@0.23.2:
+ resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
+
section-matter@1.0.0:
resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==}
engines: {node: '>=4'}
@@ -3141,6 +3215,9 @@ packages:
ufo@1.5.4:
resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==}
+ ultrahtml@1.5.3:
+ resolution: {integrity: sha512-GykOvZwgDWZlTQMtp5jrD4BVL+gNn2NVlVafjcFUJ7taY20tqYdwdoWBFy6GBJsNTZe1GkGPkSl5knQAjtgceg==}
+
undici-types@6.19.8:
resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==}
@@ -3600,6 +3677,18 @@ snapshots:
dependencies:
prismjs: 1.29.0
+ '@astrojs/react@3.6.2(@types/react-dom@18.3.0)(@types/react@18.3.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vite@5.4.3(@types/node@20.16.10))':
+ dependencies:
+ '@types/react': 18.3.10
+ '@types/react-dom': 18.3.0
+ '@vitejs/plugin-react': 4.3.2(vite@5.4.3(@types/node@20.16.10))
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ ultrahtml: 1.5.3
+ transitivePeerDependencies:
+ - supports-color
+ - vite
+
'@astrojs/sitemap@3.1.6':
dependencies:
sitemap: 7.1.2
@@ -3766,6 +3855,16 @@ snapshots:
'@babel/core': 7.25.2
'@babel/helper-plugin-utils': 7.24.8
+ '@babel/plugin-transform-react-jsx-self@7.24.7(@babel/core@7.25.2)':
+ dependencies:
+ '@babel/core': 7.25.2
+ '@babel/helper-plugin-utils': 7.24.8
+
+ '@babel/plugin-transform-react-jsx-source@7.24.7(@babel/core@7.25.2)':
+ dependencies:
+ '@babel/core': 7.25.2
+ '@babel/helper-plugin-utils': 7.24.8
+
'@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.25.2)':
dependencies:
'@babel/core': 7.25.2
@@ -4412,6 +4511,17 @@ snapshots:
dependencies:
undici-types: 6.19.8
+ '@types/prop-types@15.7.13': {}
+
+ '@types/react-dom@18.3.0':
+ dependencies:
+ '@types/react': 18.3.10
+
+ '@types/react@18.3.10':
+ dependencies:
+ '@types/prop-types': 15.7.13
+ csstype: 3.1.3
+
'@types/sax@1.2.7':
dependencies:
'@types/node': 20.16.10
@@ -4529,6 +4639,17 @@ snapshots:
'@ungap/structured-clone@1.2.0': {}
+ '@vitejs/plugin-react@4.3.2(vite@5.4.3(@types/node@20.16.10))':
+ dependencies:
+ '@babel/core': 7.25.2
+ '@babel/plugin-transform-react-jsx-self': 7.24.7(@babel/core@7.25.2)
+ '@babel/plugin-transform-react-jsx-source': 7.24.7(@babel/core@7.25.2)
+ '@types/babel__core': 7.20.5
+ react-refresh: 0.14.2
+ vite: 5.4.3(@types/node@20.16.10)
+ transitivePeerDependencies:
+ - supports-color
+
'@volar/kit@2.4.1(typescript@5.6.2)':
dependencies:
'@volar/language-service': 2.4.1
@@ -4964,6 +5085,8 @@ snapshots:
cssesc@3.0.0: {}
+ csstype@3.1.3: {}
+
data-uri-to-buffer@2.0.2: {}
debug@4.3.6:
@@ -5814,6 +5937,10 @@ snapshots:
longest-streak@3.1.0: {}
+ loose-envify@1.4.0:
+ dependencies:
+ js-tokens: 4.0.0
+
lru-cache@10.4.3: {}
lru-cache@5.1.1:
@@ -6646,6 +6773,18 @@ snapshots:
queue-microtask@1.2.3: {}
+ react-dom@18.3.1(react@18.3.1):
+ dependencies:
+ loose-envify: 1.4.0
+ react: 18.3.1
+ scheduler: 0.23.2
+
+ react-refresh@0.14.2: {}
+
+ react@18.3.1:
+ dependencies:
+ loose-envify: 1.4.0
+
read-cache@1.0.0:
dependencies:
pify: 2.3.0
@@ -6873,6 +7012,10 @@ snapshots:
sax@1.4.1: {}
+ scheduler@0.23.2:
+ dependencies:
+ loose-envify: 1.4.0
+
section-matter@1.0.0:
dependencies:
extend-shallow: 2.0.1
@@ -7151,6 +7294,8 @@ snapshots:
ufo@1.5.4: {}
+ ultrahtml@1.5.3: {}
+
undici-types@6.19.8: {}
undici@5.28.4:
diff --git a/src/dashboard-app/client/public/index.html b/src/dashboard-app/client/public/index.html
new file mode 100644
index 00000000..f1f4e328
--- /dev/null
+++ b/src/dashboard-app/client/public/index.html
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ freeCodeCamp Contributor Tools
+
+
+
+
+
+
diff --git a/src/dashboard-app/client/src/App.jsx b/src/dashboard-app/client/src/App.jsx
new file mode 100644
index 00000000..3d8cd123
--- /dev/null
+++ b/src/dashboard-app/client/src/App.jsx
@@ -0,0 +1,127 @@
+import React, { Component } from 'react';
+
+import FreeCodeCampLogo from './assets/freeCodeCampLogo';
+import Tabs from './components/Tabs';
+import Search from './components/Search';
+import Pareto from './components/Pareto';
+import Repos from './components/Repos';
+import Footer from './components/Footer';
+
+import { ENDPOINT_INFO } from './constants';
+const PageContainer = () => {
+ return {/* content */}
;
+};
+
+const Container = () => {
+ const containerStyle = {
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'center',
+ alignItems: 'center',
+ maxWidth: '960px',
+ width: '90vw',
+ padding: '15px',
+ borderRadius: '4px',
+ boxShadow: '0 0 4px 0 #777'
+ };
+
+ return {/* content */}
;
+};
+
+const AppNavBar = () => {
+ return ;
+};
+
+const logoStyle = { paddingLeft: '30px' };
+
+const titleStyle = { margin: '0', padding: '0' };
+
+class App extends Component {
+ state = {
+ view: 'search',
+ footerInfo: null
+ };
+
+ updateInfo() {
+ fetch(ENDPOINT_INFO)
+ .then(response => response.json())
+ .then(({ ok, numPRs, prRange, lastUpdate }) => {
+ if (ok) {
+ const footerInfo = { numPRs, prRange, lastUpdate };
+ this.setState(() => ({ footerInfo }));
+ }
+ })
+ .catch(() => {
+ // do nothing
+ });
+ }
+
+ handleViewChange = ({ target: { id } }) => {
+ const view = id.replace('tabs-', '');
+ this.setState(() => ({ ...this.clearObj, view }));
+ if (view === 'reports' || view === 'search') {
+ this.updateInfo();
+ }
+ };
+
+ componentDidMount() {
+ this.updateInfo();
+ }
+
+ render() {
+ const {
+ handleViewChange,
+ state: { view, footerInfo }
+ } = this;
+ return (
+ <>
+
+
+
+
+ Contributor Tools
+
+
+
+
+
+ {view === 'search' && }
+ {view === 'reports' && }
+ {view === 'boilerplates' && (
+ repo._id.includes('boilerplate')}
+ />
+ )}
+ {view === 'other' && (
+
+ !repo._id.includes('boilerplate') &&
+ repo._id !== 'freeCodeCamp'
+ }
+ />
+ )}
+
+ {footerInfo && }
+
+ >
+ );
+ }
+}
+export default App;
diff --git a/src/dashboard-app/client/src/assets/freeCodeCampLogo.jsx b/src/dashboard-app/client/src/assets/freeCodeCampLogo.jsx
new file mode 100644
index 00000000..657a6f04
--- /dev/null
+++ b/src/dashboard-app/client/src/assets/freeCodeCampLogo.jsx
@@ -0,0 +1,114 @@
+/* eslint-disable max-len */
+import React from 'react';
+
+function freeCodeCampLogo() {
+ return (
+
+ );
+}
+
+freeCodeCampLogo.displayName = 'freeCodeCampLogo';
+
+export default freeCodeCampLogo;
diff --git a/src/dashboard-app/client/src/components/FilenameResults.jsx b/src/dashboard-app/client/src/components/FilenameResults.jsx
new file mode 100644
index 00000000..c6ff0fdb
--- /dev/null
+++ b/src/dashboard-app/client/src/components/FilenameResults.jsx
@@ -0,0 +1,51 @@
+import React from 'react';
+
+import ListItem from './ListItem';
+import FullWidthDiv from './FullWidthDiv';
+import Result from './Result';
+
+const List = () => {
+ const listStyle = {
+ margin: '5px',
+ display: 'flex',
+ flexDirection: 'column'
+ };
+
+ return {/* content */}
;
+};
+
+const filenameTitle = { fontWeight: '600' };
+
+const FilenameResults = ({ searchValue, results, rateLimitMessage }) => {
+ const elements = results.map(result => {
+ const { filename, prs: prObjects } = result;
+ const prs = prObjects.map(({ number, username, title }) => {
+ return ;
+ });
+
+ const fileOnMain = `https://github.com/freeCodeCamp/freeCodeCamp/blob/main/${filename}`;
+ return (
+
+ {filename}{' '}
+
+ (File on Main)
+
+ {prs}
+
+ );
+ });
+ const showResults = () => {
+ if (!rateLimitMessage) {
+ return (
+ (results.length ? Results for: {searchValue}
: null) &&
+ elements
+ );
+ } else {
+ return rateLimitMessage;
+ }
+ };
+
+ return {showResults()};
+};
+
+export default FilenameResults;
diff --git a/src/dashboard-app/client/src/components/FilterOption.jsx b/src/dashboard-app/client/src/components/FilterOption.jsx
new file mode 100644
index 00000000..86dcc391
--- /dev/null
+++ b/src/dashboard-app/client/src/components/FilterOption.jsx
@@ -0,0 +1,23 @@
+import React from 'react';
+
+const FilterOption = ({
+ group,
+ children,
+ value,
+ selectedOption,
+ onOptionChange
+}) => {
+ return (
+
+ );
+};
+export default FilterOption;
diff --git a/src/dashboard-app/client/src/components/Footer.jsx b/src/dashboard-app/client/src/components/Footer.jsx
new file mode 100644
index 00000000..a5d3f2fe
--- /dev/null
+++ b/src/dashboard-app/client/src/components/Footer.jsx
@@ -0,0 +1,42 @@
+import React from 'react';
+
+const Container = () => {
+ const containerStyle = {
+ marginTop: '5px',
+ textAlign: 'center'
+ };
+
+ return {/* content */}
;
+};
+
+const Info = () => {
+ const infoStyle = {
+ fontSize: '14px',
+ padding: '2px'
+ };
+
+ return {/* content */}
;
+};
+
+const Footer = props => {
+ const localTime = lastUpdate => {
+ const newTime = new Date(lastUpdate);
+ return newTime.toLocaleString();
+ };
+
+ const {
+ footerInfo: { numPRs, prRange, lastUpdate }
+ } = props;
+ return (
+ lastUpdate && (
+
+ Last Update: {localTime(lastUpdate)}
+
+ # of open PRs: {numPRs} ({prRange})
+
+
+ )
+ );
+};
+
+export default Footer;
diff --git a/src/dashboard-app/client/src/components/FullWidthDiv.jsx b/src/dashboard-app/client/src/components/FullWidthDiv.jsx
new file mode 100644
index 00000000..6bc0e250
--- /dev/null
+++ b/src/dashboard-app/client/src/components/FullWidthDiv.jsx
@@ -0,0 +1,11 @@
+import React from 'react';
+
+const FullWidthDiv = () => {
+ const fullWidthStyle = {
+ width: '100%'
+ };
+
+ return {/* content */}
;
+};
+
+export default FullWidthDiv;
diff --git a/src/dashboard-app/client/src/components/Input.jsx b/src/dashboard-app/client/src/components/Input.jsx
new file mode 100644
index 00000000..10374aa2
--- /dev/null
+++ b/src/dashboard-app/client/src/components/Input.jsx
@@ -0,0 +1,21 @@
+import React from 'react';
+
+const Container = () => {
+ const containerStyle = {
+ marginBottom: '10px'
+ };
+
+ return ;
+};
+
+const Input = React.forwardRef((props, ref) => (
+
+));
+
+export default Input;
diff --git a/src/dashboard-app/client/src/components/ListItem.jsx b/src/dashboard-app/client/src/components/ListItem.jsx
new file mode 100644
index 00000000..f8fc4ffb
--- /dev/null
+++ b/src/dashboard-app/client/src/components/ListItem.jsx
@@ -0,0 +1,48 @@
+import React from 'react';
+
+const Container = () => {
+ const containerStyle = {
+ display: 'flex',
+ justifyContent: 'space-between',
+ flexDirection: 'row',
+ overflow: 'hidden'
+ };
+
+ const mediaQueryStyle =
+ window.innerWidth <= 600
+ ? {
+ marginTop: '1em',
+ flexDirection: 'column'
+ }
+ : {};
+
+ return (
+ {/* content */}
+ );
+};
+
+const prNumStyle = { flex: 1 };
+const usernameStyle = { flex: 1 };
+const titleStyle = { flex: 3 };
+
+const ListItem = ({ number, username, prTitle: title, prLink }) => {
+ const prUrl = prLink
+ ? prLink
+ : `https://github.com/freeCodeCamp/freeCodeCamp/pull/${number}`;
+ return (
+
+
+ #{number}
+
+ {username}
+ {title}
+
+ );
+};
+
+export default ListItem;
diff --git a/src/dashboard-app/client/src/components/Pareto.jsx b/src/dashboard-app/client/src/components/Pareto.jsx
new file mode 100644
index 00000000..4ca07817
--- /dev/null
+++ b/src/dashboard-app/client/src/components/Pareto.jsx
@@ -0,0 +1,242 @@
+import React from 'react';
+
+import ListItem from './ListItem';
+import FullWidthDiv from './FullWidthDiv';
+import Result from './Result';
+import FilterOption from './FilterOption';
+import { ENDPOINT_PARETO } from '../constants';
+
+const List = () => {
+ const listStyle = {
+ margin: '5px',
+ display: 'flex',
+ flexDirection: 'column'
+ };
+
+ return {/* content */}
;
+};
+
+const Options = () => {
+ const optionsStyle = {
+ display: 'flex'
+ };
+
+ return {/* content */}
;
+};
+
+const detailsStyle = { padding: '3px' };
+const filenameTitle = { fontWeight: '600' };
+
+class Pareto extends React.Component {
+ state = {
+ data: [],
+ all: [],
+ selectedFileType: 'all',
+ selectedLanguage: 'all',
+ options: {},
+ rateLimitMessage: ''
+ };
+
+ componentDidMount() {
+ fetch(ENDPOINT_PARETO)
+ .then(response => response.json())
+ .then(({ ok, rateLimitMessage, pareto }) => {
+ if (ok) {
+ if (!pareto.length) {
+ pareto.push({
+ filename: 'Nothing to show in Pareto Report',
+ count: 0,
+ prs: []
+ });
+ }
+
+ this.setState(() => ({
+ data: pareto,
+ all: [...pareto],
+ options: this.createOptions(pareto)
+ }));
+ } else if (rateLimitMessage) {
+ this.setState(() => ({
+ rateLimitMessage
+ }));
+ }
+ })
+ .catch(() => {
+ const pareto = [
+ { filename: 'Nothing to show in Pareto Report', count: 0, prs: [] }
+ ];
+ this.setState(() => ({ data: pareto }));
+ });
+ }
+
+ createOptions = data => {
+ const options = data.reduce((seen, { filename }) => {
+ const { articleType, language } = this.getFilenameOptions(filename);
+ if (articleType && language) {
+ if (!Object.prototype.hasOwnProperty.call(seen, articleType)) {
+ seen[articleType] = {};
+ }
+ seen[articleType][language] = true;
+ }
+ return seen;
+ }, {});
+ return options;
+ };
+
+ handleFileTypeOptionChange = changeEvent => {
+ let { all, selectedLanguage, options } = this.state;
+ const selectedFileType = changeEvent.target.value;
+
+ let data = [...all].filter(({ filename }) => {
+ const { articleType, language } = this.getFilenameOptions(filename);
+ let condition;
+ if (selectedFileType === 'all') {
+ condition = true;
+ selectedLanguage = 'all';
+ } else {
+ if (selectedLanguage === 'all') {
+ condition = articleType === selectedFileType;
+ } else if (!options[selectedFileType][selectedLanguage]) {
+ condition = articleType === selectedFileType;
+ selectedLanguage = 'all';
+ } else {
+ condition =
+ articleType === selectedFileType && language === selectedLanguage;
+ }
+ }
+ return condition;
+ });
+ this.setState(() => ({
+ data,
+ selectedFileType,
+ selectedLanguage
+ }));
+ };
+
+ handleLanguageOptionChange = changeEvent => {
+ const { all, selectedFileType } = this.state;
+ const selectedLanguage = changeEvent.target.value;
+ let data = [...all].filter(({ filename }) => {
+ const { articleType, language } = this.getFilenameOptions(filename);
+ let condition;
+ if (selectedLanguage === 'all') {
+ condition = articleType === selectedFileType;
+ } else {
+ condition =
+ language === selectedLanguage && articleType === selectedFileType;
+ }
+ return condition;
+ });
+ this.setState(() => ({ data, selectedLanguage }));
+ };
+
+ getFilenameOptions = filename => {
+ const filenameReplacement = filename.replace(
+ /^curriculum\/challenges\//,
+ 'curriculum/'
+ );
+ const regex =
+ /^(docs|curriculum|guide)(?:\/)(english|arabic|chinese|portuguese|russian|spanish)?\/?/;
+ // need an array to pass to labelsAdder
+ // eslint-disable-next-line
+ const [_, articleType, language] = filenameReplacement.match(regex) || [];
+ return { articleType, language };
+ };
+
+ render() {
+ const {
+ data,
+ options,
+ selectedFileType,
+ selectedLanguage,
+ rateLimitMessage
+ } = this.state;
+ const elements = rateLimitMessage
+ ? rateLimitMessage
+ : data.map(entry => {
+ const { filename, count, prs } = entry;
+ const prsList = prs.map(({ number, username, title }) => {
+ return (
+
+ );
+ });
+ const fileOnMain = `https://github.com/freeCodeCamp/freeCodeCamp/blob/main/${filename}`;
+ return (
+
+ {filename}{' '}
+
+ (File on Main)
+
+
+
+ # of PRs: {count}
+ {prsList}
+
+
+ );
+ });
+
+ let fileTypeOptions = Object.keys(options).map(articleType => articleType);
+ const typeOptions = ['all', ...fileTypeOptions].map(type => (
+
+ {type.charAt().toUpperCase() + type.slice(1)}
+
+ ));
+
+ let languageOptions = null;
+ if (selectedFileType !== 'all') {
+ let languages = Object.keys(options[selectedFileType]);
+ languages = ['all', ...languages.sort()];
+ languageOptions = languages.map(language => (
+
+ {language.charAt().toUpperCase() + language.slice(1)}
+
+ ));
+ }
+ return (
+
+ {fileTypeOptions.length > 0 && Filter Options}
+
+ {fileTypeOptions.length > 0 && (
+ <>
+
+ >
+ )}
+ {languageOptions && (
+
+ )}
+
+ {rateLimitMessage
+ ? rateLimitMessage
+ : data.length
+ ? elements
+ : 'Report Loading...'}
+
+ );
+ }
+}
+
+export default Pareto;
diff --git a/src/dashboard-app/client/src/components/PrResults.jsx b/src/dashboard-app/client/src/components/PrResults.jsx
new file mode 100644
index 00000000..ade2fc1a
--- /dev/null
+++ b/src/dashboard-app/client/src/components/PrResults.jsx
@@ -0,0 +1,52 @@
+import React from 'react';
+
+import ListItem from './ListItem';
+import FullWidthDiv from './FullWidthDiv';
+import Result from './Result';
+
+const List = () => {
+ const listStyle = {
+ margin: '3px'
+ };
+
+ return ;
+};
+
+const PrResults = ({ searchValue, results, rateLimitMessage }) => {
+ const elements = results.map((result, idx) => {
+ const { number, filenames, username, title } = result;
+ const files = filenames.map((filename, index) => {
+ const fileOnMain = `https://github.com/freeCodeCamp/freeCodeCamp/blob/main/${filename}`;
+ return (
+
+ {filename}{' '}
+
+ (File on Main)
+
+
+ );
+ });
+
+ return (
+
+
+ {files}
+
+ );
+ });
+
+ const showResults = () => {
+ if (!rateLimitMessage) {
+ return (
+ (results.length ? Results for PR# {searchValue}
: null) &&
+ elements
+ );
+ } else {
+ return rateLimitMessage;
+ }
+ };
+
+ return {showResults()};
+};
+
+export default PrResults;
diff --git a/src/dashboard-app/client/src/components/Repos.jsx b/src/dashboard-app/client/src/components/Repos.jsx
new file mode 100644
index 00000000..e415a752
--- /dev/null
+++ b/src/dashboard-app/client/src/components/Repos.jsx
@@ -0,0 +1,103 @@
+import React from 'react';
+
+import ListItem from './ListItem';
+import FullWidthDiv from './FullWidthDiv';
+import Result from './Result';
+import { ENDPOINT_ALL_REPOS } from '../constants';
+
+const List = () => {
+ const listStyle = {
+ margin: '5px',
+ display: 'flex',
+ flexDirection: 'column'
+ };
+
+ return {/* content */}
;
+};
+
+const detailsStyle = { padding: '3px' };
+const filenameTitle = { fontWeight: '600' };
+
+class Repos extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ data: [],
+ rateLimitMessage: ''
+ };
+ }
+
+ componentDidMount() {
+ fetch(ENDPOINT_ALL_REPOS)
+ .then(response => response.json())
+ .then(({ ok, rateLimitMessage, allRepos }) => {
+ if (ok) {
+ const repos = allRepos.filter(this.props.dataFilter);
+ if (!repos.length) {
+ repos.push({
+ repoName: 'No repos with open PRs',
+ prs: []
+ });
+ }
+
+ this.setState(() => ({
+ data: repos
+ }));
+ } else if (rateLimitMessage) {
+ this.setState(() => ({
+ rateLimitMessage
+ }));
+ }
+ })
+ .catch(() => {
+ const repos = [{ repoName: 'No repos with open PRs', prs: [] }];
+ this.setState(() => ({ data: repos }));
+ });
+ }
+
+ render() {
+ const { data, rateLimitMessage } = this.state;
+
+ const elements = rateLimitMessage
+ ? rateLimitMessage
+ : data.map(entry => {
+ const { _id: repoName, prs } = entry;
+ const prsList = prs.map(
+ ({ _id: number, username, title, prLink }) => {
+ return (
+
+ );
+ }
+ );
+
+ return (
+
+ {repoName}
+
+
+ # of PRs: {prs.length}
+ {prsList}
+
+
+ );
+ });
+
+ return (
+
+ {rateLimitMessage
+ ? rateLimitMessage
+ : data.length
+ ? elements
+ : 'Report Loading...'}
+
+ );
+ }
+}
+export default Repos;
diff --git a/src/dashboard-app/client/src/components/Result.jsx b/src/dashboard-app/client/src/components/Result.jsx
new file mode 100644
index 00000000..ed96113b
--- /dev/null
+++ b/src/dashboard-app/client/src/components/Result.jsx
@@ -0,0 +1,12 @@
+const Result = ({ index, children }) => {
+ const resultStyle = {
+ border: '1px solid #aaa',
+ margin: '10px 0',
+ padding: '3px',
+ background: index % 2 === 0 ? '#eee' : 'transparent' // handles odd/even background
+ };
+
+ return {children}
;
+};
+
+export default Result;
diff --git a/src/dashboard-app/client/src/components/Search.jsx b/src/dashboard-app/client/src/components/Search.jsx
new file mode 100644
index 00000000..76ba0f98
--- /dev/null
+++ b/src/dashboard-app/client/src/components/Search.jsx
@@ -0,0 +1,140 @@
+import React, { Component } from 'react';
+
+import Input from './Input';
+import PrResults from './PrResults';
+import FilenameResults from './FilenameResults';
+import SearchOption from './SearchOption';
+
+import { ENDPOINT_PR, ENDPOINT_SEARCH } from '../constants';
+class Search extends Component {
+ state = {
+ searchValue: '',
+ selectedOption: 'pr',
+ results: [],
+ message: ''
+ };
+
+ clearObj = { searchValue: '', results: [] };
+
+ inputRef = React.createRef();
+
+ handleInputEvent = event => {
+ const {
+ type,
+ key,
+ target: { value: searchValue }
+ } = event;
+
+ if (type === 'change') {
+ if (this.state.selectedOption === 'pr') {
+ if (Number(searchValue) || searchValue === '') {
+ this.setState(() => ({ searchValue, results: [] }));
+ }
+ } else {
+ this.setState(() => ({ searchValue, results: [] }));
+ }
+ } else if (type === 'keypress' && key === 'Enter') {
+ this.searchPRs(searchValue);
+ }
+ };
+
+ handleButtonClick = () => {
+ const { searchValue } = this.state;
+ if (searchValue) {
+ this.searchPRs(searchValue);
+ } else {
+ this.inputRef.current.focus();
+ }
+ };
+
+ handleOptionChange = changeEvent => {
+ const selectedOption = changeEvent.target.value;
+
+ this.setState(() => ({ selectedOption, ...this.clearObj }));
+ this.inputRef.current.focus();
+ };
+
+ searchPRs = value => {
+ const { selectedOption } = this.state;
+
+ const fetchUrl =
+ selectedOption === 'pr'
+ ? `${ENDPOINT_PR}/${value}`
+ : `${ENDPOINT_SEARCH}/?value=${value}`;
+
+ fetch(fetchUrl)
+ .then(response => response.json())
+ .then(({ ok, message, results, rateLimitMessage }) => {
+ if (ok) {
+ this.setState(() => ({ message, results }));
+ } else if (rateLimitMessage) {
+ this.setState(() => ({
+ rateLimitMessage
+ }));
+ }
+ })
+ .catch(() => {
+ this.setState(() => this.clearObj);
+ });
+ };
+
+ componentDidMount() {
+ this.inputRef.current.focus();
+ }
+
+ render() {
+ const {
+ handleButtonClick,
+ handleInputEvent,
+ inputRef,
+ handleOptionChange,
+ state
+ } = this;
+ const { searchValue, message, results, selectedOption, rateLimitMessage } =
+ state;
+
+ return (
+ <>
+
+
+ PR #
+
+
+ Filename
+
+
+
+
+ {message}
+ {selectedOption === 'pr' && (
+
+ )}
+ {selectedOption === 'filename' && (
+
+ )}
+ >
+ );
+ }
+}
+
+export default Search;
diff --git a/src/dashboard-app/client/src/components/SearchOption.jsx b/src/dashboard-app/client/src/components/SearchOption.jsx
new file mode 100644
index 00000000..02f6f07e
--- /dev/null
+++ b/src/dashboard-app/client/src/components/SearchOption.jsx
@@ -0,0 +1,16 @@
+import React from 'react';
+
+const SearchOption = ({ children, value, selectedOption, onOptionChange }) => (
+
+);
+
+export default SearchOption;
diff --git a/src/dashboard-app/client/src/components/Tabs.jsx b/src/dashboard-app/client/src/components/Tabs.jsx
new file mode 100644
index 00000000..79b04f02
--- /dev/null
+++ b/src/dashboard-app/client/src/components/Tabs.jsx
@@ -0,0 +1,77 @@
+import React from 'react';
+
+const Container = () => {
+ const containerStyle = {
+ display: 'flex',
+ justifyContent: 'center',
+ height: '40px',
+ margin: '20px'
+ };
+
+ return {/* content */}
;
+};
+const Tab = ({ active, theme, children }) => {
+ const tabStyle = {
+ background: active ? theme.primary : 'white',
+ color: active ? 'white' : theme.primary,
+ fontSize: '18px',
+ padding: '5px',
+ border: `2px solid ${theme.primary}`,
+ borderLeft: 'none',
+ width: '200px',
+ textAlign: 'center',
+ cursor: 'pointer'
+ };
+
+ // media query handling
+ if (window.innerWidth <= 600) {
+ tabStyle.width = 'auto';
+ tabStyle.minWidth = '100px';
+ }
+
+ // hover effect
+ const handleMouseEnter = () => {
+ tabStyle.background = theme.primary;
+ tabStyle.color = 'white';
+ };
+
+ const handleMouseLeave = () => {
+ tabStyle.background = active ? theme.primary : 'white';
+ tabStyle.color = active ? 'white' : theme.primary;
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+const Tabs = ({ view, onViewChange }) => {
+ return (
+
+
+ Search
+
+
+ Pareto
+
+
+ Boilerplate PRs
+
+
+ Other Repos' PRs
+
+
+ );
+};
+
+export default Tabs;
diff --git a/src/dashboard-app/client/src/constants/index.js b/src/dashboard-app/client/src/constants/index.js
new file mode 100644
index 00000000..bef66614
--- /dev/null
+++ b/src/dashboard-app/client/src/constants/index.js
@@ -0,0 +1,15 @@
+let API_HOST = '';
+
+const ENDPOINT_INFO = API_HOST + '/info';
+const ENDPOINT_PARETO = API_HOST + '/pareto';
+const ENDPOINT_ALL_REPOS = API_HOST + '/all-repos';
+const ENDPOINT_PR = API_HOST + '/pr';
+const ENDPOINT_SEARCH = API_HOST + '/search';
+export {
+ API_HOST,
+ ENDPOINT_INFO,
+ ENDPOINT_PARETO,
+ ENDPOINT_PR,
+ ENDPOINT_SEARCH,
+ ENDPOINT_ALL_REPOS
+};
diff --git a/src/dashboard-app/client/src/fonts/Lato-Bold.ttf b/src/dashboard-app/client/src/fonts/Lato-Bold.ttf
new file mode 100644
index 00000000..74343694
Binary files /dev/null and b/src/dashboard-app/client/src/fonts/Lato-Bold.ttf differ
diff --git a/src/dashboard-app/client/src/fonts/Lato-Light.ttf b/src/dashboard-app/client/src/fonts/Lato-Light.ttf
new file mode 100644
index 00000000..a958067a
Binary files /dev/null and b/src/dashboard-app/client/src/fonts/Lato-Light.ttf differ
diff --git a/src/dashboard-app/client/src/fonts/Lato-Regular.ttf b/src/dashboard-app/client/src/fonts/Lato-Regular.ttf
new file mode 100644
index 00000000..04ea8efb
Binary files /dev/null and b/src/dashboard-app/client/src/fonts/Lato-Regular.ttf differ
diff --git a/src/dashboard-app/client/src/index.css b/src/dashboard-app/client/src/index.css
new file mode 100644
index 00000000..c8437222
--- /dev/null
+++ b/src/dashboard-app/client/src/index.css
@@ -0,0 +1,67 @@
+@font-face {
+ font-family: 'Lato';
+ src: url(./fonts/Lato-Regular.ttf) format('truetype');
+ font-display: fallback;
+}
+
+@font-face {
+ font-family: 'Lato Light';
+ src: url(./fonts/Lato-Light.ttf) format('truetype');
+ font-display: fallback;
+}
+
+@font-face {
+ font-family: 'Lato Bold';
+ src: url(./fonts/Lato-Bold.ttf) format('truetype');
+ font-display: fallback;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ padding: 0;
+ font-family: Lato, sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.app-menu {
+ margin: 0;
+ padding: 13px 15px;
+ margin-right: 15px;
+
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ list-style: none;
+}
+
+.app-menu li a {
+ padding: 13px 15px;
+ color: white;
+ font-size: 20px;
+ font-weight: 700;
+}
+
+.app-menu li a:hover {
+ color: #0a0a23;
+ background: white;
+}
+
+a {
+ text-decoration: none;
+ color: #0a0a23;
+}
+
+a:visited {
+ text-decoration: none;
+ color: purple;
+}
+
+code {
+ font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
+ monospace;
+}
diff --git a/src/dashboard-app/client/src/serviceWorker.js b/src/dashboard-app/client/src/serviceWorker.js
new file mode 100644
index 00000000..2283ff9c
--- /dev/null
+++ b/src/dashboard-app/client/src/serviceWorker.js
@@ -0,0 +1,135 @@
+// This optional code is used to register a service worker.
+// register() is not called by default.
+
+// This lets the app load faster on subsequent visits in production, and gives
+// it offline capabilities. However, it also means that developers (and users)
+// will only see deployed updates on subsequent visits to a page, after all the
+// existing tabs open on the page have been closed, since previously cached
+// resources are updated in the background.
+
+// To learn more about the benefits of this model and instructions on how to
+// opt-in, read http://bit.ly/CRA-PWA
+
+const isLocalhost = Boolean(
+ window.location.hostname === 'localhost' ||
+ // [::1] is the IPv6 localhost address.
+ window.location.hostname === '[::1]' ||
+ // 127.0.0.1/8 is considered localhost for IPv4.
+ window.location.hostname.match(
+ /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
+ )
+);
+
+export function register(config) {
+ if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
+ // The URL constructor is available in all browsers that support SW.
+ const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
+ if (publicUrl.origin !== window.location.origin) {
+ // Our service worker won't work if PUBLIC_URL is on a different origin
+ // from what our page is served on. This might happen if a CDN is used to
+ // serve assets; see https://github.com/facebook/create-react-app/issues/2374
+ return;
+ }
+
+ window.addEventListener('load', () => {
+ const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
+
+ if (isLocalhost) {
+ // This is running on localhost. Let's check if a service worker still exists or not.
+ checkValidServiceWorker(swUrl, config);
+
+ // Add some additional logging to localhost, pointing developers to the
+ // service worker/PWA documentation.
+ navigator.serviceWorker.ready.then(() => {
+ console.log(
+ 'This web app is being served cache-first by a service ' +
+ 'worker. To learn more, visit http://bit.ly/CRA-PWA'
+ );
+ });
+ } else {
+ // Is not localhost. Just register service worker
+ registerValidSW(swUrl, config);
+ }
+ });
+ }
+}
+
+function registerValidSW(swUrl, config) {
+ navigator.serviceWorker
+ .register(swUrl)
+ .then(registration => {
+ registration.onupdatefound = () => {
+ const installingWorker = registration.installing;
+ if (installingWorker == null) {
+ return;
+ }
+ installingWorker.onstatechange = () => {
+ if (installingWorker.state === 'installed') {
+ if (navigator.serviceWorker.controller) {
+ // At this point, the updated precached content has been fetched,
+ // but the previous service worker will still serve the older
+ // content until all client tabs are closed.
+ console.log(
+ 'New content is available and will be used when all ' +
+ 'tabs for this page are closed. See http://bit.ly/CRA-PWA.'
+ );
+
+ // Execute callback
+ if (config && config.onUpdate) {
+ config.onUpdate(registration);
+ }
+ } else {
+ // At this point, everything has been precached.
+ // It's the perfect time to display a
+ // "Content is cached for offline use." message.
+ console.log('Content is cached for offline use.');
+
+ // Execute callback
+ if (config && config.onSuccess) {
+ config.onSuccess(registration);
+ }
+ }
+ }
+ };
+ };
+ })
+ .catch(error => {
+ console.error('Error during service worker registration:', error);
+ });
+}
+
+function checkValidServiceWorker(swUrl, config) {
+ // Check if the service worker can be found. If it can't reload the page.
+ fetch(swUrl)
+ .then(response => {
+ // Ensure service worker exists, and that we really are getting a JS file.
+ const contentType = response.headers.get('content-type');
+ if (
+ response.status === 404 ||
+ (contentType != null && contentType.indexOf('javascript') === -1)
+ ) {
+ // No service worker found. Probably a different app. Reload the page.
+ navigator.serviceWorker.ready.then(registration => {
+ registration.unregister().then(() => {
+ window.location.reload();
+ });
+ });
+ } else {
+ // Service worker found. Proceed as normal.
+ registerValidSW(swUrl, config);
+ }
+ })
+ .catch(() => {
+ console.log(
+ 'No internet connection found. App is running in offline mode.'
+ );
+ });
+}
+
+export function unregister() {
+ if ('serviceWorker' in navigator) {
+ navigator.serviceWorker.ready.then(registration => {
+ registration.unregister();
+ });
+ }
+}
diff --git a/src/dashboard-app/client/src/theme/index.js b/src/dashboard-app/client/src/theme/index.js
new file mode 100644
index 00000000..c44fcc23
--- /dev/null
+++ b/src/dashboard-app/client/src/theme/index.js
@@ -0,0 +1,5 @@
+const theme = {
+ primary: '#0a0a23'
+};
+
+export default theme;
diff --git a/src/pages/dashboard.astro b/src/pages/dashboard.astro
new file mode 100644
index 00000000..753ceed6
--- /dev/null
+++ b/src/pages/dashboard.astro
@@ -0,0 +1,5 @@
+---
+import App from '../dashboard-app/client/src/App.jsx';
+---
+
+