├── .dockerignore ├── .eslintrc.json ├── .gitignore ├── Dockerfile ├── Dockerfile.template ├── README.md ├── __tests__ ├── dev-server-setup-modules │ ├── global-setup.js │ └── global-teardown.js └── puppeteer.test.ts ├── jest-puppeteer.config.js ├── jest-puppeteer.setup.js ├── jest.config.js ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── App-Preview.png ├── NEO-banner.png ├── Neo-White.png ├── Neo.png ├── NeoDemo2FastGif.gif ├── NeoFaviconV1.png ├── NeoFaviconV2.png ├── NeoMainFastGif.gif ├── NeoSelection.gif ├── NeoUploadFastGif.gif ├── benson-pfp.jpeg ├── donald-pfp.jpeg ├── github-logo-black.png ├── github-logo.png ├── google-icon.png ├── justin-pfp.jpeg ├── linkedin-logo.png ├── next.svg ├── nitesh-pfp.jpeg ├── play-button.png ├── tom-pfp.jpeg └── vercel.svg ├── src ├── app │ ├── Footer.tsx │ ├── NavBar.tsx │ ├── api │ │ ├── auth │ │ │ └── [...nextauth] │ │ │ │ ├── authOptions.ts │ │ │ │ └── route.ts │ │ ├── cleanUp │ │ │ └── route.ts │ │ ├── fileUpload │ │ │ ├── algoMetrics.ts │ │ │ ├── dockerController.ts │ │ │ └── route.ts │ │ ├── puppeteerHandler │ │ │ ├── puppeteer.ts │ │ │ └── route.ts │ │ ├── signup │ │ │ └── route.ts │ │ └── sqlController │ │ │ ├── PostgresAdapter.ts │ │ │ ├── setup.sql │ │ │ └── sql.ts │ ├── contact │ │ ├── card.tsx │ │ └── page.tsx │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ ├── neo │ │ ├── app.tsx │ │ ├── clear-tree.tsx │ │ ├── donut.tsx │ │ ├── input.tsx │ │ └── page.tsx │ ├── page.tsx │ ├── providers │ │ └── Provider.tsx │ ├── signin │ │ └── page.tsx │ └── signup │ │ └── page.tsx ├── instrumentation.ts └── middleware.ts ├── tailwind.config.js └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # zipped/unzip files 9 | /upload 10 | 11 | # testing 12 | /coverage 13 | 14 | # next.js 15 | /.next/ 16 | /out/ 17 | 18 | # production 19 | /build 20 | 21 | # misc 22 | .DS_Store 23 | *.pem 24 | 25 | # debug 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | 30 | # local env files 31 | .env*.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | next-env.d.ts 39 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | 2 | FROM node:18 3 | 4 | WORKDIR /app/neo 5 | 6 | COPY package*.json ./ 7 | 8 | COPY . /app/neo/ 9 | 10 | RUN npm ci 11 | 12 | RUN npm run build 13 | 14 | EXPOSE 3000 15 | 16 | CMD [ "npm", "start" ] -------------------------------------------------------------------------------- /Dockerfile.template: -------------------------------------------------------------------------------- 1 | 2 | FROM node:18 3 | 4 | WORKDIR /app/users-app/ 5 | 6 | COPY package*.json ./ 7 | 8 | COPY . /app/users-app/ 9 | 10 | RUN npm i 11 | 12 | RUN npm i @vercel/otel @opentelemetry/sdk-node @opentelemetry/resources @opentelemetry/semantic-conventions @opentelemetry/sdk-trace-base @opentelemetry/exporter-trace-otlp-http 13 | 14 | RUN npm run build 15 | 16 | EXPOSE 3000 17 | 18 | CMD [ "npm", "start" ] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | NEO 3 |

4 | 5 | # Next Engine Optimization 6 | 7 | Next Engine Optimization (NEO) is a web application for helping developers hone in on performance metrics centered around SEO. NEO is built for applications made with Next.js and aims to provide metrics during development so that engineers can make data-driven decisions on their code. 8 | 9 | --- 10 | 11 | ## Tech Stack 12 | 13 | ![JavaScript](https://img.shields.io/badge/javascript-%23323330.svg?style=for-the-badge&logo=javascript&logoColor=%23F7DF1E) 14 | ![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white) 15 | ![Next.js](https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white) 16 | ![React](https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB) 17 | ![HTML5](https://img.shields.io/badge/html5-%23E34F26.svg?style=for-the-badge&logo=html5&logoColor=white) 18 | ![CSS3](https://img.shields.io/badge/css3-%231572B6.svg?style=for-the-badge&logo=css3&logoColor=white) 19 | ![TailwindCSS](https://img.shields.io/badge/Tailwind_CSS-38B2AC?style=for-the-badge&logo=tailwind-css&logoColor=white) 20 | ![NPM](https://img.shields.io/badge/npm-CB3837?style=for-the-badge&logo=npm&logoColor=white) 21 | ![NodeJS](https://img.shields.io/badge/node.js-6DA55F?style=for-the-badge&logo=node.js&logoColor=white) 22 | ![Express](https://img.shields.io/badge/Express.js-000000?style=for-the-badge&logo=express&logoColor=white) 23 | ![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white) 24 | ![GoogleChrome](https://img.shields.io/badge/Google_chrome-4285F4?style=for-the-badge&logo=Google-chrome&logoColor=white) 25 | ![Chart.js](https://img.shields.io/badge/Chart.js-FF6384?style=for-the-badge&logo=chartdotjs&logoColor=white) 26 | ![Webpack](https://img.shields.io/badge/webpack-%238DD6F9.svg?style=for-the-badge&logo=webpack&logoColor=black) 27 | ![Jest](https://img.shields.io/badge/-jest-%23C21325?style=for-the-badge&logo=jest&logoColor=white) 28 | ![Puppeteer](https://img.shields.io/badge/Puppeteer-40B5A4?style=for-the-badge&logo=Puppeteer&logoColor=white) 29 | ![Babel](https://img.shields.io/badge/Babel-F9DC3e?style=for-the-badge&logo=babel&logoColor=black) 30 | ![eslint](https://img.shields.io/badge/eslint-3A33D1?style=for-the-badge&logo=eslint&logoColor=white) 31 | ![PostgreSQL](https://img.shields.io/badge/PostgreSQL-316192?style=for-the-badge&logo=postgresql&logoColor=white) 32 | ![Git](https://img.shields.io/badge/GIT-E44C30?style=for-the-badge&logo=git&logoColor=white) 33 | 34 | --- 35 | 36 | ## Motivation 37 | 38 | Plenty of tools offer performance metrics post-deployment, but NEO brings the same level of metrics during the development process. NEO also provides metrics focused around SEO, so that developers can optimize their application's search engine performance during development. 39 | 40 | --- 41 | 42 | ## How can I use NEO? 43 | 44 | 1. Head directly to our website: LINK HERE 45 | 46 | 2. Click 'Sign In' in the top right corner 47 | 48 | 3. Sign up for an account/Sign in if you already have an account 49 | 50 | 4. Head to the App page LINK HERE 51 | 52 | 5. Upload a Next.js application (**We currently only support Next.js applications that use the new App router with the src directory**) 53 | 54 |

55 | NEO-Upload 56 |

57 | 58 | 6. Click on a page directory (bolded) 59 | 60 | 7. Click on 'Generate' for your metrics! 61 | 62 |

63 | NEO-Selection 64 |

65 | 66 | ## Contributors 67 | 68 | | Developed By | | | 69 | | :------------: | :---------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------: | 70 | | Benson Zhen | [![Github](https://img.shields.io/badge/github-%23121011.svg?style=for-the-badge&logo=github&logoColor=white)](https://github.com/bensonzhen) | [![LinkedIn](https://img.shields.io/badge/LinkedIn-%230077B5.svg?logo=linkedin&logoColor=white)](https://www.linkedin.com/in/bensonzhen/) | 71 | | Donald Twiford | [![Github](https://img.shields.io/badge/github-%23121011.svg?style=for-the-badge&logo=github&logoColor=white)](https://github.com/KrankyKnight) | [![LinkedIn](https://img.shields.io/badge/LinkedIn-%230077B5.svg?logo=linkedin&logoColor=white)](https://www.linkedin.com/in/donaldtwiford/) | 72 | | Justin Shim | [![Github](https://img.shields.io/badge/github-%23121011.svg?style=for-the-badge&logo=github&logoColor=white)](https://github.com/slip4k) | [![LinkedIn](https://img.shields.io/badge/LinkedIn-%230077B5.svg?logo=linkedin&logoColor=white)](https://www.linkedin.com/in/justinshim/) | 73 | | Nitesh Sunku | [![Github](https://img.shields.io/badge/github-%23121011.svg?style=for-the-badge&logo=github&logoColor=white)](https://github.com/nsunku99) | [![LinkedIn](https://img.shields.io/badge/LinkedIn-%230077B5.svg?logo=linkedin&logoColor=white)](https://www.linkedin.com/in/niteshsunku/) | 74 | | Tom Nguyen | [![Github](https://img.shields.io/badge/github-%23121011.svg?style=for-the-badge&logo=github&logoColor=white)](https://github.com/nguyentomt) | [![LinkedIn](https://img.shields.io/badge/LinkedIn-%230077B5.svg?logo=linkedin&logoColor=white)](https://www.linkedin.com/in/nguyentomt/) | 75 | -------------------------------------------------------------------------------- /__tests__/dev-server-setup-modules/global-setup.js: -------------------------------------------------------------------------------- 1 | /* Export async function globalSetup() */ 2 | /* Creates an instance of nextjs dev server on port 3000 if it is not in use */ 3 | 4 | const { setup: setupDevServer } = require("jest-dev-server"); 5 | 6 | module.exports = async function globalSetup() { 7 | globalThis.servers = await setupDevServer({ 8 | command: `npm run dev`, 9 | port: 3000, 10 | usedPortAction: "ignore", 11 | }); 12 | }; -------------------------------------------------------------------------------- /__tests__/dev-server-setup-modules/global-teardown.js: -------------------------------------------------------------------------------- 1 | /* Export function globalTeardown() */ 2 | /* Shuts down testing server instances created by global-setup module function globalSetup() */ 3 | 4 | const { teardown: teardownDevServer } = require("jest-dev-server"); 5 | 6 | module.exports = async function globalTeardown() { 7 | await teardownDevServer(globalThis.servers); 8 | } 9 | 10 | -------------------------------------------------------------------------------- /__tests__/puppeteer.test.ts: -------------------------------------------------------------------------------- 1 | /* Testing Suite built around puppeteer */ 2 | /* Uses a test application built in NextJS named 'bboy-blog' constructed by Tom Nguyen @ https://github.com/nguyentomt */ 3 | 4 | import puppeteer from "puppeteer"; 5 | import * as fsX from 'fs-extra'; 6 | import globalSetup from './dev-server-setup-modules/global-setup'; 7 | import globalTeardown from './dev-server-setup-modules/global-teardown'; 8 | 9 | //variables for test paths 10 | const APP = `http://localhost:3000`; 11 | const zipStorage = './upload/zip'; 12 | const unzipStorage = './upload/unzip'; 13 | const testFolder = './__tests__/test-app'; 14 | 15 | describe('Client side features', () => { 16 | let browser: any; 17 | let page: any; 18 | 19 | //start server and prepare a puppeteer page for navigation 20 | beforeAll(async () => { 21 | await globalSetup(); 22 | browser = await puppeteer.launch({ headless: 'new' }); 23 | page = await browser.newPage(); 24 | //ensure storage files on server are empty 25 | fsX.emptyDirSync(zipStorage); 26 | fsX.emptyDirSync(unzipStorage); 27 | }); 28 | 29 | 30 | describe('Initial load', () => { 31 | it('loads successfully, testing for hidden header', async () => { 32 | await page.goto(APP), 33 | await page.waitForSelector('#pageHeaderHome'); 34 | const label: string = await page.$eval('#pageHeaderHome', (el: any) => el.innerText); 35 | expect(label).toBe('Home'); 36 | }, 10000); 37 | }); 38 | 39 | describe('Nav bar should navigate to correct pages', () => { 40 | it('navbar "App" button navigates to /signin page if authentication hasn\'t been provided, testing for hidden header', async () => { 41 | await page.goto(APP); 42 | await page.waitForSelector('#navApp'); 43 | await page.click('#navApp'); 44 | await page.waitForNavigation({ waitUntil: 'domcontentloaded' }); 45 | const url: string = await page.url(); 46 | expect(url.includes('signin')); 47 | }); 48 | 49 | it('navbar "Contact" button navigates to /contact page, testing for hidden header', async () => { 50 | await page.goto(APP); 51 | await page.waitForSelector('#navContact'); 52 | await page.click('#navContact'); 53 | await page.waitForSelector('#pageHeaderContact'); 54 | const label: string = await page.$eval('#pageHeaderContact', (el: HTMLElement) => el.innerText) 55 | expect(label).toBe('Contact'); 56 | }); 57 | 58 | it('navbar "Sign In" button navigates to /signin page, testing for hidden header', async () => { 59 | await page.goto(APP); 60 | await page.waitForSelector('#signIn'); 61 | await page.click('#signIn'); 62 | await page.waitForSelector('#pageHeaderSignIn'); 63 | const label: string = await page.$eval('#pageHeaderSignIn', (el: HTMLElement) => el.innerText) 64 | expect(label).toBe('Sign-In'); 65 | }) 66 | 67 | it('navbar "Home" button navigates to / page, testing for hidden header', async () => { 68 | await page.goto(APP); 69 | await page.waitForSelector('#navHome'); 70 | await page.click('#navHome'); 71 | await page.waitForSelector('#pageHeaderHome'); 72 | const label: string = await page.$eval('#pageHeaderHome', (el: HTMLElement) => el.innerText) 73 | expect(label).toBe('Home'); 74 | }); 75 | }); 76 | 77 | //Following tests skipped until dummy login test is implemented 78 | 79 | xdescribe('File structure should display in sidebar after upload', () => { 80 | it('is empty until input is selected', async () => { 81 | await page.goto(APP + '/neo'); 82 | await page.waitForSelector('#pageHeaderNeo'); 83 | const sidebarChildren: HTMLElement = await page.$('#fileStructure'); 84 | expect(sidebarChildren).toBe(null); 85 | }) 86 | 87 | it('generates file list after input is clicked', async () => { 88 | await page.goto(APP + '/neo'); 89 | await page.waitForSelector('#pageHeaderNeo'); 90 | //invoke input and select test folder 91 | const [fileChooser] = await Promise.all([ 92 | page.waitForFileChooser(), 93 | page.click('#fileInput'), 94 | ]); 95 | await fileChooser.accept([testFolder]); 96 | //clean up upload files 97 | await page.waitForResponse(async (response: any) => { 98 | return (await response); 99 | }); 100 | fsX.emptyDirSync(zipStorage); 101 | fsX.emptyDirSync(unzipStorage); 102 | //test that elements mounted in sidebar have content 103 | await page.waitForSelector('#fileStructure'); 104 | const label: string = await page.$eval('#fileStructure', (el: HTMLElement) => el.innerText) 105 | expect(label.length).toBeGreaterThan(0); 106 | }) 107 | }) 108 | 109 | xdescribe('Generate and Reset buttons should add and remove graphs', () => { 110 | it('pressing Generate should create content', async () => { 111 | await page.goto(APP + '/neo'); 112 | await page.waitForSelector('#handleGen'); 113 | //function for current status of hidden 114 | async function hiddenStatus() { 115 | const hidden = await page.$eval('#all-charts', (el: HTMLElement) => { 116 | return el.getAttribute('hidden'); 117 | }) 118 | return hidden 119 | } 120 | expect(await hiddenStatus()).toBeTruthy(); 121 | await page.click('#handleGen'); 122 | expect(await hiddenStatus()).toBeNull(); 123 | await page.click('#reset'); 124 | expect(await hiddenStatus()).toBeTruthy; 125 | }) 126 | }) 127 | 128 | //close puppeteer browser and shut down server if loaded 129 | afterAll(async () => { 130 | await browser.close(); 131 | await globalTeardown(); 132 | }); 133 | }) 134 | 135 | /* 136 | Future testing suites: 137 | - Create a dummy login to simulate authentication 138 | - Check server pathways 139 | - Check Generate and Reset button functionality 140 | - Check chart functionality 141 | - Test contact links 142 | */ -------------------------------------------------------------------------------- /jest-puppeteer.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | launch: { 3 | headless: false, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /jest-puppeteer.setup.js: -------------------------------------------------------------------------------- 1 | const { setup: setupPuppeteer } = require('jest-environment-puppeteer'); 2 | 3 | module.exports = async () => { 4 | await setupPuppeteer(); 5 | // Additional setup steps if needed 6 | }; 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | testPathIgnorePatterns: ["test-app", "dev-server-setup-modules"], 6 | }; -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | instrumentationHook: true, 5 | }, 6 | images: { 7 | remotePatterns: [ 8 | { 9 | protocol: 'https', 10 | hostname: '**.licdn.com', 11 | }, 12 | { 13 | protocol: 'https', 14 | hostname: 'ca.slack-edge.com', 15 | }, 16 | ], 17 | }, 18 | }; 19 | 20 | module.exports = nextConfig; 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "neo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "test": "jest test --detectOpenHandles" 11 | }, 12 | "dependencies": { 13 | "@auth/sequelize-adapter": "^1.0.1", 14 | "@opentelemetry/exporter-trace-otlp-http": "^0.41.0", 15 | "@opentelemetry/resources": "^1.15.0", 16 | "@opentelemetry/sdk-node": "^0.41.0", 17 | "@opentelemetry/sdk-trace-base": "^1.15.0", 18 | "@opentelemetry/semantic-conventions": "^1.15.0", 19 | "@tailwindcss/forms": "^0.5.4", 20 | "@types/decompress": "^4.2.4", 21 | "@types/file-saver": "^2.0.5", 22 | "@types/fs-extra": "^11.0.1", 23 | "@types/node": "20.3.2", 24 | "@types/react": "18.2.14", 25 | "@types/react-dom": "18.2.6", 26 | "@vercel/otel": "^0.3.0", 27 | "autoprefixer": "10.4.14", 28 | "axios": "^1.4.0", 29 | "bcrypt": "^5.1.0", 30 | "chart.js": "^4.3.0", 31 | "decompress": "^4.2.1", 32 | "eslint": "8.43.0", 33 | "eslint-config-next": "13.4.7", 34 | "file-saver": "^2.0.5", 35 | "fs": "^0.0.1-security", 36 | "fs-extra": "^11.1.1", 37 | "jszip": "^3.10.1", 38 | "next": "13.4.7", 39 | "next-auth": "^4.22.1", 40 | "next-connect": "^1.0.0-next.4", 41 | "path": "^0.12.7", 42 | "pg": "^8.11.1", 43 | "postcss": "8.4.24", 44 | "puppeteer": "^20.8.2", 45 | "react": "18.2.0", 46 | "react-dom": "18.2.0", 47 | "swr": "^2.2.0", 48 | "tailwindcss": "3.3.2", 49 | "ts-node": "^10.9.1" 50 | }, 51 | "devDependencies": { 52 | "@babel/core": "^7.22.8", 53 | "@babel/preset-env": "^7.22.7", 54 | "@babel/preset-typescript": "^7.22.5", 55 | "@types/bcrypt": "^5.0.0", 56 | "@types/chart.js": "^2.9.37", 57 | "@types/jest": "^29.5.3", 58 | "@types/pg": "^8.10.2", 59 | "babel-jest": "^29.6.1", 60 | "jest": "^29.6.1", 61 | "jest-dev-server": "^9.0.0", 62 | "ts-jest": "^29.1.1", 63 | "typescript": "^5.1.6" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/App-Preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Neo/280cf55a762e26a6427ecaf07df2d5f1426234e6/public/App-Preview.png -------------------------------------------------------------------------------- /public/NEO-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Neo/280cf55a762e26a6427ecaf07df2d5f1426234e6/public/NEO-banner.png -------------------------------------------------------------------------------- /public/Neo-White.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Neo/280cf55a762e26a6427ecaf07df2d5f1426234e6/public/Neo-White.png -------------------------------------------------------------------------------- /public/Neo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Neo/280cf55a762e26a6427ecaf07df2d5f1426234e6/public/Neo.png -------------------------------------------------------------------------------- /public/NeoDemo2FastGif.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Neo/280cf55a762e26a6427ecaf07df2d5f1426234e6/public/NeoDemo2FastGif.gif -------------------------------------------------------------------------------- /public/NeoFaviconV1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Neo/280cf55a762e26a6427ecaf07df2d5f1426234e6/public/NeoFaviconV1.png -------------------------------------------------------------------------------- /public/NeoFaviconV2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Neo/280cf55a762e26a6427ecaf07df2d5f1426234e6/public/NeoFaviconV2.png -------------------------------------------------------------------------------- /public/NeoMainFastGif.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Neo/280cf55a762e26a6427ecaf07df2d5f1426234e6/public/NeoMainFastGif.gif -------------------------------------------------------------------------------- /public/NeoSelection.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Neo/280cf55a762e26a6427ecaf07df2d5f1426234e6/public/NeoSelection.gif -------------------------------------------------------------------------------- /public/NeoUploadFastGif.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Neo/280cf55a762e26a6427ecaf07df2d5f1426234e6/public/NeoUploadFastGif.gif -------------------------------------------------------------------------------- /public/benson-pfp.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Neo/280cf55a762e26a6427ecaf07df2d5f1426234e6/public/benson-pfp.jpeg -------------------------------------------------------------------------------- /public/donald-pfp.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Neo/280cf55a762e26a6427ecaf07df2d5f1426234e6/public/donald-pfp.jpeg -------------------------------------------------------------------------------- /public/github-logo-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Neo/280cf55a762e26a6427ecaf07df2d5f1426234e6/public/github-logo-black.png -------------------------------------------------------------------------------- /public/github-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Neo/280cf55a762e26a6427ecaf07df2d5f1426234e6/public/github-logo.png -------------------------------------------------------------------------------- /public/google-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Neo/280cf55a762e26a6427ecaf07df2d5f1426234e6/public/google-icon.png -------------------------------------------------------------------------------- /public/justin-pfp.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Neo/280cf55a762e26a6427ecaf07df2d5f1426234e6/public/justin-pfp.jpeg -------------------------------------------------------------------------------- /public/linkedin-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Neo/280cf55a762e26a6427ecaf07df2d5f1426234e6/public/linkedin-logo.png -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/nitesh-pfp.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Neo/280cf55a762e26a6427ecaf07df2d5f1426234e6/public/nitesh-pfp.jpeg -------------------------------------------------------------------------------- /public/play-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Neo/280cf55a762e26a6427ecaf07df2d5f1426234e6/public/play-button.png -------------------------------------------------------------------------------- /public/tom-pfp.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Neo/280cf55a762e26a6427ecaf07df2d5f1426234e6/public/tom-pfp.jpeg -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/Footer.tsx: -------------------------------------------------------------------------------- 1 | /* Contents of footer displayed at all routes */ 2 | 3 | import Link from "next/link"; 4 | import Image from 'next/image' 5 | 6 | export default function Footer() { 7 | return ( 8 | 23 | ) 24 | } -------------------------------------------------------------------------------- /src/app/NavBar.tsx: -------------------------------------------------------------------------------- 1 | /* Contents of navigation bar displayed at all routes */ 2 | 3 | import Link from "next/link"; 4 | import Image from 'next/image'; 5 | import { getServerSession } from "next-auth";; 6 | import { authOptions } from "./api/auth/[...nextauth]/authOptions"; 7 | 8 | export default async function NavBar() { 9 | 10 | const session = await getServerSession(authOptions); 11 | 12 | return ( 13 | 33 | ) 34 | } -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/authOptions.ts: -------------------------------------------------------------------------------- 1 | /* Auth Set-Up and Imports*/ 2 | /* */ 3 | 4 | import { NextAuthOptions } from "next-auth"; 5 | import Credentials from "next-auth/providers/credentials"; 6 | import GoogleProvider from "next-auth/providers/google"; 7 | import GithubProvider from "next-auth/providers/github"; 8 | import { Pool } from "pg"; 9 | import PostgresAdapter from "../../sqlController/PostgresAdapter"; 10 | import connectToDatabase from "../../sqlController/sql"; 11 | import bcrypt from 'bcrypt'; 12 | 13 | // Connecting to PostgreSQL Database 14 | const pg_URI = process.env.DATABASE_URL; 15 | 16 | const pool = new Pool({ 17 | connectionString: pg_URI 18 | }) 19 | 20 | // Auth Options defines the forms of Credentials: Email and Password + OAuth 21 | export const authOptions: NextAuthOptions = { 22 | providers: [ 23 | Credentials({ 24 | name: "Email", 25 | credentials: { 26 | name: { label: "Name", type: "name" }, 27 | email: { label: "Email", type: "email" }, 28 | password: { label: "Password", type: "password" } 29 | }, 30 | async authorize(credentials, req) { 31 | 32 | // authorization via database check and bcrypt 33 | const { dbClient, dbRelease } = await connectToDatabase(); 34 | 35 | try { 36 | 37 | if (!credentials?.email || !credentials?.password) { 38 | throw new Error('Missing Fields'); 39 | } 40 | 41 | console.log(credentials.email, credentials.password); 42 | 43 | // check if in database 44 | const user = ` 45 | SELECT * FROM users 46 | WHERE users.email = $1 47 | `; 48 | 49 | const response = await dbClient?.query(user, [credentials.email]); 50 | console.log('users password', response?.rows[0].password) 51 | console.log('response', response?.rows); 52 | 53 | if (response && response.rows.length > 0) { 54 | const passMatch = await bcrypt.compare(credentials.password, response.rows[0].password); 55 | console.log(passMatch); 56 | console.log(typeof credentials.password); 57 | 58 | if (!passMatch) throw new Error('Incorrect Password'); 59 | else return response.rows[0]; 60 | 61 | } else { 62 | throw new Error('User does not exist'); 63 | } 64 | 65 | } catch (error) { 66 | throw error; 67 | } finally { 68 | if (dbClient && dbRelease) dbRelease(); 69 | } 70 | 71 | } 72 | }), 73 | // Github Provider not set up 74 | GithubProvider({ 75 | clientId: process.env.GITHUB_ID as string, 76 | clientSecret: process.env.GITHUB_SECRET as string, 77 | }), 78 | GoogleProvider({ 79 | clientId: process.env.GOOGLE_ID as string, 80 | clientSecret: process.env.GOOGLE_SECRET as string, 81 | authorization: { 82 | params: { 83 | prompt: "consent", 84 | access_type: "offline", 85 | response_type: "code" 86 | } 87 | } 88 | }), 89 | ], 90 | secret: process.env.SECRET, 91 | session: { 92 | strategy: 'jwt', 93 | maxAge: 15 * 60 94 | }, 95 | debug: process.env.NODE_ENV === 'development', 96 | adapter: PostgresAdapter(pool), // connecting to custom adapter that connects auth to DB 97 | pages: { // manually set routes to created pages (sign in and sign up) 98 | signIn: '/signin', 99 | newUser: '/signup' 100 | } 101 | } -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | // API Route for Next Auth Connection 2 | 3 | import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions"; 4 | import NextAuth from "next-auth/next"; 5 | 6 | const handler = NextAuth(authOptions); 7 | 8 | export { handler as GET, handler as POST } 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/app/api/cleanUp/route.ts: -------------------------------------------------------------------------------- 1 | /* Server route for emptying uploaded file storage folders */ 2 | 3 | import { NextResponse } from 'next/server' 4 | import { NextRequest } from 'next/server' 5 | import { createEdgeRouter } from "next-connect"; 6 | import * as fsX from 'fs-extra'; 7 | 8 | //setup for router 9 | interface RequestContext { 10 | params: { 11 | id: string; 12 | }; 13 | } 14 | 15 | //create router 16 | const router = createEdgeRouter(); 17 | 18 | router 19 | //target and delete uploaded files in containing folders 20 | .get(async(req, event, next) => { 21 | console.log('in router') 22 | fsX.emptyDirSync('./upload/zip'); 23 | fsX.emptyDirSync('./upload/unzip'); 24 | return NextResponse.json('File upload deleted') 25 | }) 26 | 27 | export async function GET(request: NextRequest, ctx: RequestContext) { 28 | return router.run(request, ctx); 29 | } -------------------------------------------------------------------------------- /src/app/api/fileUpload/algoMetrics.ts: -------------------------------------------------------------------------------- 1 | /* the argument has to be an object type for TS */ 2 | 3 | export function algoMetrics(metrics: any) { 4 | const metricsObj: {[key: string]: string | number} = {}; 5 | 6 | // grading object for FCP, DC, and Req Time all of the times are rounded up and then compared to this rubric to provide the raw score 7 | const obj: {[key: string]: string} = { 8 | 0: '100', 100: '100', 200: '98', 300: '96', 400: '94', 500: '92', 600: '90', 700: '88', 800: '86', 900: '84', 1000: '82', 9 | 1100: '80', 1200: '78', 1300: '76', 1400: '74', 1500: '72', 1600: '70', 1700: '68', 1800: '66', 1900: '64', 2000: '62', 10 | 2100: '60', 2200: '58', 2300: '56', 2400: '54', 2500: '52', 2600: '50', 2700: '48', 2800: '46', 2900: '44', 3000: '42', 3100: '40', 11 | 3200: '38', 3300: '36', 3400: '34', 3500: '30', 3600: '28', 3700: '26', 3800: '24', 3900: '22', 4000: '20', 12 | 4100: '18', 4200: '16', 4300: '14', 4500: '12', 4600: '10', 4700: '8', 4800: '6', 4900: '4', 5000: '2', 5100: '0' 13 | }; 14 | 15 | // grading object for only the hydration metric, all of the times are rounded up and then compared to this rubric to provide the raw score 16 | const hydrationObj: {[key: string]: string} = { 17 | 1: '100', 2: '98', 3: '96', 4: '94', 5: '92', 6: '90', 7: '88', 8: '86', 9: '84', 10: '82', 18 | 11: '80', 12: '78', 13: '76', 14: '74', 15: '72', 16: '70', 17: '68', 18: '66', 19: '64', 20: '62', 19 | 21: '60', 22: '58', 23: '56', 24: '54', 25: '52', 26: '50', 27: '48', 28: '46', 29: '44', 30: '42' 20 | }; 21 | 22 | // FCP metrics 23 | if (metrics.FCP <= 1800) { 24 | metricsObj.FCP = 'First contentful paint: ' + metrics.FCP + ' rating: good'; 25 | metricsObj.FCPNum = Math.round(metrics.FCP * 100) / 100; // round to the nearest hundredth 26 | let roundScore = Math.round(metrics.FCP / 100) * 100; // round to the nearest 100 27 | console.log(roundScore); 28 | metricsObj.FCPScore = obj[roundScore]; 29 | metricsObj.FCPColor = 'green'; 30 | } 31 | else if (metrics.FCP <= 3000) { 32 | metricsObj.FCP = 'First contentful paint: ' + metrics.FCP + ' rating: average'; 33 | metricsObj.FCPNum = Math.round(metrics.FCP * 100) / 100; 34 | let roundScore = Math.round(metrics.FCP / 100) * 100; 35 | metricsObj.FCPScore = obj[roundScore]; 36 | metricsObj.FCPColor = 'yellow'; 37 | } else { 38 | metricsObj.FCP = 'First contentful paint: ' + metrics.FCP + ' rating: bad'; 39 | metricsObj.FCPNum = Math.round(metrics.FCP * 100) / 100; 40 | let roundScore = Math.round(metrics.FCP / 100) * 100; 41 | metricsObj.FCPScore = obj[roundScore]; 42 | metricsObj.FCPColor = 'red'; 43 | } 44 | 45 | if (metrics.RequestTime <= 1800) { 46 | metricsObj.RequestTime = 'Total Request Time' + metrics.RequestTime + ' rating: good'; 47 | metricsObj.RequestNum = Math.round(metrics.RequestTime * 100) / 100; 48 | let reqRoundScore = Math.round(metrics.RequestTime / 100) * 100; 49 | metricsObj.RequestScore = obj[reqRoundScore]; 50 | metricsObj.RequestColor = 'green'; 51 | } else if (metrics.RequestTime <= 3000) { 52 | metricsObj.RequestTime = 'Total Request Time' + metrics.RequestTime + ' rating: average'; 53 | metricsObj.RequestNum = Math.round(metrics.RequestTime * 100) / 100; 54 | let reqRoundScore = Math.round(metrics.RequestTime / 100) * 100; 55 | metricsObj.RequestScore = obj[reqRoundScore]; 56 | metricsObj.RequestColor = 'yellow'; 57 | } else { 58 | metricsObj.RequestTime = 'Total Request Time' + metrics.RequestTime + ' rating: average'; 59 | metricsObj.RequestNum = Math.round(metrics.RequestTime * 100) / 100; 60 | let reqRoundScore = Math.round(metrics.RequestTime / 100) * 100; 61 | metricsObj.RequestScore = obj[reqRoundScore]; 62 | metricsObj.RequestColor = 'red'; 63 | } 64 | 65 | 66 | // DOM Score metrics 67 | if (metrics.DOMCompletion <= 1800) { 68 | metricsObj.domComplete = 'DOM Completion time: ' + metrics.DOMCompletion + ' rating: good'; 69 | metricsObj.domCompleteNum = Math.round(metrics.DOMCompletion * 100) / 100; 70 | let roundDom = Math.round(metrics.DOMCompletion / 100) * 100; 71 | console.log('roundDom: ' + roundDom); 72 | roundDom < 300 ? roundDom = 300 : null; 73 | metricsObj.domScore = obj[roundDom]; 74 | metricsObj.domColor = 'green'; 75 | } else if (metrics.DOMCompletion <= 3000) { 76 | metricsObj.domComplete = 'DOM Completion time: ' + metrics.DOMCompletion + ' rating: average'; 77 | metricsObj.domCompleteNum = Math.round(metrics.DOMCompletion * 100) / 100; 78 | let roundDom = Math.round(metrics.DOMCompletion / 100) * 100; 79 | metricsObj.domScore = obj[roundDom]; 80 | metricsObj.domColor = 'yellow'; 81 | } else { 82 | metricsObj.domComplete = 'DOM Completion time: ' + metrics.DOMCompletion + ' rating: bad'; 83 | metricsObj.domCompleteNum = Math.round(metrics.DOMCompletion * 100) / 100; 84 | let roundDom = Math.round(metrics.DOMCompletion / 100) * 100; 85 | metricsObj.domScore = obj[roundDom]; 86 | metricsObj.domColor = 'red'; 87 | } 88 | 89 | //Hydration Metrics 90 | if (metrics.HydrationTime <= 10) { 91 | metricsObj.Hydration = 'Hydration Time: ' + metrics.HydrationTime + ' rating: good'; 92 | metricsObj.HydrationNum = Math.round(metrics.HydrationTime * 100) / 100; 93 | let hydrationRoundScore = Math.round(metrics.HydrationTime); 94 | metricsObj.HydrationScore = hydrationObj[hydrationRoundScore]; 95 | metricsObj.HydrationColor = 'green'; 96 | } 97 | else if (metrics.HydrationTime <= 24) { 98 | metricsObj.Hydration = 'Hydration Time: ' + metrics.HydrationTime + ' rating: average'; 99 | metricsObj.HydrationNum = Math.round(metrics.HydrationTime * 100) / 100; 100 | let hydrationRoundScore = Math.round(metrics.HydrationTime); 101 | metricsObj.HydrationScore = hydrationObj[hydrationRoundScore]; 102 | metricsObj.HydrationColor = 'yellow'; 103 | } else { 104 | metricsObj.Hydration = 'Hydration Time: ' + metrics.HydrationTime + ' rating: bad'; 105 | metricsObj.HydrationNum = Math.round(metrics.HydrationTime * 100) / 100; 106 | let hydrationRoundScore = Math.round(metrics.HydrationTime); 107 | metricsObj.HydrationScore = hydrationObj[hydrationRoundScore]; 108 | metricsObj.HydrationColor = 'red'; 109 | } 110 | return metricsObj; 111 | } -------------------------------------------------------------------------------- /src/app/api/fileUpload/dockerController.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Docker Controller contains functions for: 3 | - Add Files: Creating Dockerfile and Docker Ignore in Users App 4 | - Build and Run: Stopping any previous instance of Docker Container, Creating Image, and Running a Container, and setting a stop terminal command to close and remove the container and image after 15 minutes 5 | */ 6 | 7 | import { exec } from 'child_process'; 8 | import { promisify } from 'util'; 9 | import fs from 'fs'; 10 | const execSync = promisify(exec); 11 | 12 | export const dockerFuncs = { 13 | 14 | AddFiles: async ({ newAppPath, dockerFilePath }: { [key: string]: unknown }): Promise => { 15 | 16 | try { 17 | fs.cp('Dockerfile.template', `${dockerFilePath}`, err => { 18 | if (err) console.log('error while adding Dockerfile: ', err); 19 | }) 20 | 21 | fs.cp('.dockerignore', `${newAppPath}/.dockerignore`, err => { 22 | if (err) console.log('error while adding Dockerfile: ', err); 23 | }) 24 | // COPYING THE DEFAULT VERCEL INSTRUMENTATION SCRIPT INTO THE CONTAINER 25 | // For devs: we were trying to set up OpenTelemetry in the user containers so that we could retreive metrics at a componenet level 26 | fs.cp('src/instrumentation.ts', `${newAppPath}/src/instrumentation.ts`, err => { 27 | if (err) console.log('error while adding Dockerfile: ', err); 28 | }) 29 | 30 | } catch (error) { 31 | console.error('error in docker add files middleware handler'); 32 | console.error(error); 33 | } 34 | 35 | }, 36 | 37 | BuildAndRun: async ({ appname, newAppPath, dockerFilePath, port, email }: { [key: string]: unknown }): Promise => { 38 | 39 | const name = (email as string).replace('@', '.'); 40 | 41 | const dockerSetup: string = ` 42 | docker stop ${name} 43 | docker rm ${name} 44 | docker build -f ${dockerFilePath} -t ${appname} ${newAppPath} 45 | docker run -d -p ${port}:3000 --rm --name ${name} -it ${appname} 46 | `; 47 | 48 | // Build Docker Image and Run Docker Container on Randomized Port 49 | try { 50 | const { stdout, stderr }: { stdout: string, stderr: string } = await execSync(dockerSetup) 51 | console.log('stdout: ' + stdout); 52 | console.log('stderr: ' + stderr); 53 | } catch (error) { 54 | console.error('stderr: ' + error); 55 | throw error; 56 | } 57 | 58 | // Stop after 15 minutes (900 seconds) 59 | const dockerStop: string = `sleep 900 && docker stop ${name} && docker rmi ${appname} &`; 60 | exec(dockerStop, (error, stdout, stderr) => { 61 | if (error) { 62 | console.error(`exec error: ${error}`); 63 | return; 64 | } 65 | console.log(`stdout: ${stdout}`); 66 | console.error(`stderr: ${stderr}`); 67 | }) 68 | } 69 | 70 | } 71 | 72 | -------------------------------------------------------------------------------- /src/app/api/fileUpload/route.ts: -------------------------------------------------------------------------------- 1 | /* 2 | File Upload API Connection: 3 | - Clears Upload Directory and Uploads New File as a Zip 4 | - Creates Docker Image and Runs Docker Container 5 | - Assigns Docker Container Port to User based on Credentials 6 | */ 7 | 8 | import { NextResponse, NextRequest } from 'next/server' 9 | import { createEdgeRouter } from "next-connect"; 10 | import decompress from 'decompress'; 11 | import fs from 'fs'; 12 | import * as fsX from 'fs-extra'; 13 | import { dockerFuncs } from './dockerController'; 14 | import connectToDatabase from '../sqlController/sql'; 15 | const { AddFiles, BuildAndRun } = dockerFuncs; 16 | 17 | //SETUP FOR NEXT-CONNECT ROUTER 18 | interface RequestContext { 19 | params: { 20 | id: string; 21 | }; 22 | }; 23 | 24 | // EXTEND NEXT-REQUEST TO TAKE A LOCALS OBJECT FOR DATA PASSING 25 | interface ExtraNextReq extends NextRequest { 26 | locals: { 27 | [key: string]: unknown; // use like res.locals, now req.locals 28 | } 29 | }; 30 | 31 | const ports: number[] = [9090, 16686, 14268, 14250, 9411, 1888, 8888, 8889, 13133, 4317, 4318, 55679]; 32 | 33 | //NEXT-CONNECT ROUTER 34 | const router = createEdgeRouter(); 35 | 36 | router 37 | //FILE CLEANUP 38 | .post(async (req, event, next) => { 39 | fsX.emptyDirSync('./upload/zip'); 40 | fsX.emptyDirSync('./upload/unzip'); 41 | return next(); 42 | }) 43 | 44 | //CREATE ZIP 45 | .post(async (req, event, next) => { 46 | const blobZip: Blob = await req.blob(); 47 | const fileBuffer: ArrayBuffer = await blobZip.arrayBuffer(); 48 | const data: DataView = new DataView(fileBuffer); 49 | fs.writeFileSync('upload/zip/files.zip', data); 50 | return next(); 51 | }) 52 | 53 | /* UNPACK ZIP FILE */ 54 | .post(async (req, res, next) => { 55 | await decompress('upload/zip/files.zip', 'upload/unzip'); 56 | fsX.emptyDirSync('upload/zip'); 57 | fs.rmdirSync('upload/zip'); 58 | 59 | // intitialize req.locals 60 | req.locals = {}; 61 | 62 | // save name of App 63 | req.locals.appname = fs.readdirSync('upload/unzip')[0].toLowerCase(); 64 | 65 | // save users name 66 | const searchParams: URLSearchParams = new URL(req.url).searchParams; 67 | const email: string | null = new URLSearchParams(searchParams).get('email'); 68 | req.locals.email = email; 69 | 70 | return next(); 71 | }) 72 | 73 | // DOCKER PROCESS 74 | 75 | // ADD DOCKERFILE, .DOCKERIGNORE, AND INSTRUMENTATION FILE 76 | .post(async (req, event, next) => { 77 | 78 | const { appname } = req.locals; 79 | req.locals.newAppPath = `upload/unzip/${appname}`; 80 | req.locals.dockerFilePath = `upload/unzip/${appname}/Dockerfile.user`; 81 | 82 | AddFiles(req.locals); 83 | 84 | return next(); 85 | }) 86 | 87 | // BUILD AND DEPLOY DOCKER CONTAINER AT RANDOMIZED PORT 88 | .post(async (req, event, next) => { 89 | 90 | const generatePort = (): number => Math.round(Math.random() * 10000 + 1000); 91 | 92 | let bool: boolean = true; 93 | while (bool) { 94 | const port: number = generatePort(); 95 | if (ports.includes(port)) continue; 96 | else { 97 | ports.push(port) 98 | bool = false; 99 | }; 100 | } 101 | 102 | // assign port name and save to memory 103 | const port: number = ports[ports.length - 1]; 104 | req.locals.port = port; 105 | 106 | await BuildAndRun(req.locals); 107 | 108 | return next() 109 | }) 110 | 111 | // UPDATE DATABASE WITH USER'S PORT 112 | .post(async (req, event, next) => { 113 | 114 | const { email, port } = req.locals; 115 | 116 | const { dbClient, dbRelease } = await connectToDatabase(); 117 | 118 | try { 119 | 120 | const updatePort = ` 121 | UPDATE users 122 | SET port = $1 123 | WHERE users.email = $2 124 | RETURNING * 125 | `; 126 | 127 | await dbClient?.query(updatePort, [port, email]); 128 | 129 | } catch (error) { 130 | throw new Error('Error Updating database with User and Port') 131 | } finally { 132 | if (dbClient && dbRelease) dbRelease(); 133 | } 134 | 135 | await new Promise(wait => setTimeout(wait, 3000)); 136 | 137 | return NextResponse.json({ message: 'Files successfully loaded', port }); 138 | 139 | }) 140 | 141 | export async function POST(request: ExtraNextReq, ctx: RequestContext) { 142 | return router.run(request, ctx); 143 | } -------------------------------------------------------------------------------- /src/app/api/puppeteerHandler/puppeteer.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Puppeteer Handler: 3 | - Puppeteer Analyzer: Function that loads the port of the docker container to gain desired Metrics 4 | - Puppeteer opens a headless browser and goes to desired link 5 | - Gain metrics on that page 6 | - Close the puppeteer headless browser and send the metrics 7 | */ 8 | 9 | import puppeteer, { Browser, Page } from 'puppeteer'; 10 | import { algoMetrics } from '../fileUpload/algoMetrics'; 11 | 12 | let algoMetricsResult: any; 13 | 14 | export const puppeteerAnalyzer = async (endpoint: string, port: number, host: string, protocol: string): Promise => { 15 | 16 | try { 17 | 18 | console.log('Entered Puppeteer Analyzer'); 19 | const browser: Browser = await puppeteer.launch({ headless: 'new' }); 20 | const page: Page = await browser.newPage(); 21 | 22 | console.log(port); 23 | 24 | let bool: boolean = true; 25 | while (bool) { 26 | try { 27 | await Promise.all([ 28 | page.goto(`http://${host}:${port}${endpoint}`), 29 | page.waitForNavigation({ waitUntil: 'domcontentloaded' }), 30 | ]); 31 | bool = false; 32 | } catch (error) { 33 | if (error) await page.reload(); 34 | } 35 | } 36 | 37 | console.log(`On ${protocol}//${host}:${port}${endpoint}`); 38 | 39 | // OBTAIN ENTRIES WITH PERFORMANCE API GET ENTRIES METHOD 40 | const getEntries = await page.evaluate(function (): string { 41 | return JSON.stringify(window.performance.getEntries()); 42 | }) 43 | 44 | 45 | // PARSE OBJECT OF ENTRIES 46 | const parseEntries: { [key: string]: any } = JSON.parse(getEntries); 47 | const filteredEntries = parseEntries.filter((e: any) => { 48 | return e.entryType === 'navigation' || e.entryType === 'paint' || e.entryType === 'measure' 49 | }); 50 | 51 | // PRE DEFINE VARIABLES 52 | let resStartTime: number = 0; 53 | let FCP: number = 0; 54 | let reqTotal: number = 0; 55 | let hydrationTotal: number = 0; 56 | let domCompleteTime: number = 0 57 | 58 | // ITERATE THROUGH FILTERED ENTRIES, PERFORM NECESSARY CALCULATIONS, AND STORE IN VARIABLES 59 | for (let i = 0; i < filteredEntries.length; i++) { 60 | if (filteredEntries[i].entryType === 'navigation') { 61 | resStartTime = filteredEntries[i].responseStart; 62 | reqTotal = filteredEntries[i].responseEnd - filteredEntries[i].requestStart; 63 | domCompleteTime = filteredEntries[i].domComplete - filteredEntries[i].requestStart 64 | } 65 | if (filteredEntries[i].name === 'first-contentful-paint') { 66 | FCP = filteredEntries[i].startTime - resStartTime; 67 | } 68 | if (filteredEntries[i].name === 'Next.js-hydration') { 69 | hydrationTotal = filteredEntries[i].duration 70 | } 71 | } 72 | 73 | // ANALYZE DESIRED VARAIABLES FROM PERFORMANCE METRICS IN ALGOMETRICS 74 | algoMetricsResult = await algoMetrics({ 75 | FCP: FCP, 76 | RequestTime: reqTotal, 77 | HydrationTime: hydrationTotal, 78 | DOMCompletion: domCompleteTime 79 | }); 80 | 81 | await browser.close(); 82 | 83 | return algoMetricsResult; 84 | 85 | } catch (error) { 86 | 87 | console.error('Error in Puppeteer Middleware Handler'); 88 | console.error(error); 89 | throw error; 90 | 91 | } 92 | }; 93 | -------------------------------------------------------------------------------- /src/app/api/puppeteerHandler/route.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Puppeteer API Route: 3 | - Obtains data from Front End to gain metrics from Puppeteer Analyzer Function 4 | */ 5 | 6 | import { NextRequest, NextResponse } from 'next/server' 7 | import { puppeteerAnalyzer } from './puppeteer'; 8 | 9 | export async function POST(request: NextRequest) { 10 | 11 | try { 12 | 13 | const body = await request.json(); 14 | 15 | const { endpoint, port, host, protocol }: { endpoint: string, port: number, host: string, protocol: string } = body; 16 | 17 | // CREATE METRICS OBJECT & INVOKE PUPPETEER HEADLESS BROWSER FOR METRICS GATHERING 18 | const metrics = await puppeteerAnalyzer(endpoint, port, host, protocol); 19 | 20 | return NextResponse.json({ message: 'Puppeteer Analyzer Complete!', metrics }); 21 | 22 | } catch (error) { 23 | throw new Error('Error in Puppeteer Handler'); 24 | } 25 | 26 | } -------------------------------------------------------------------------------- /src/app/api/signup/route.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Sign Up API Route: 3 | - Sign Up for Users w/ Email and Password 4 | - Connects to SQL Database to store information 5 | - Encrypts password for storage via Bcrypt 6 | */ 7 | 8 | import bcrypt from 'bcrypt'; 9 | import { NextRequest, NextResponse } from 'next/server'; 10 | import connectToDatabase from '../sqlController/sql'; 11 | 12 | export async function POST(request: NextRequest) { 13 | 14 | const { dbClient, dbRelease } = await connectToDatabase(); 15 | 16 | try { 17 | 18 | const body = await request.json(); 19 | const { name, email, password }: { name: string, email: string, password: string } = body; 20 | 21 | if (!name || !email || !password) { 22 | return new NextResponse('Missing Fields', { status: 400 }); 23 | } 24 | 25 | // CHECK IN DATABASE 26 | const checkUser = ` 27 | SELECT * FROM users 28 | WHERE users.email = $1 29 | `; 30 | 31 | const response = await dbClient?.query(checkUser, [email]); 32 | 33 | if (response && response.rows.length > 0) { 34 | return new NextResponse('Account already exists', { status: 401 }); 35 | } 36 | 37 | // HASH PASSWORD FOR SECURITY 38 | const hashPass = await bcrypt.hash(password, 10); 39 | 40 | // CREATE USER IN DB WITH HASHED PASSWORD 41 | const addUser = ` 42 | INSERT INTO users (name, email, password) 43 | VALUES ($1, $2, $3) 44 | RETURNING * 45 | `; 46 | 47 | const addResponse = await dbClient?.query(addUser, [name, email, hashPass]); 48 | 49 | if (addResponse) { 50 | return NextResponse.json(addResponse.rows[0], { status: 200 }); 51 | } 52 | 53 | return new NextResponse('Failed to create account', { status: 500 }); 54 | 55 | } catch (error) { 56 | 57 | console.error('Error in sign up'); 58 | console.error(error); 59 | 60 | return NextResponse.json({ Error: error }, { status: 500 }); 61 | 62 | } finally { 63 | if (dbClient && dbRelease) dbRelease(); 64 | } 65 | 66 | } 67 | 68 | -------------------------------------------------------------------------------- /src/app/api/sqlController/PostgresAdapter.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Postgres Adapter for Next Auth: 3 | - All Functions relevant for storing User and Account information in SQL DB 4 | */ 5 | 6 | 7 | import { Pool } from "pg"; 8 | import { Account } from "next-auth"; 9 | import { 10 | Adapter, 11 | AdapterAccount, 12 | AdapterSession, 13 | AdapterUser, 14 | VerificationToken, 15 | } from "next-auth/adapters"; 16 | 17 | export default function PostgresAdapter(pool: Pool): Adapter { 18 | try { 19 | const createUser = async ( 20 | user: Omit 21 | ): Promise => { 22 | const { name, email, image } = user; 23 | const { rows } = await pool.query(` 24 | INSERT INTO users (name, email, image) 25 | VALUES ($1, $2, $3) 26 | RETURNING id, name, email, email_verified, image`, 27 | [name, email, image]); 28 | const newUser: AdapterUser = { 29 | ...rows[0], 30 | id: rows[0].id.toString(), 31 | emailVerified: rows[0].email_verified, 32 | email: rows[0].email, 33 | }; 34 | return newUser; 35 | }; 36 | 37 | const getUser = async (id: string) => { 38 | const { rows } = await pool.query(` 39 | SELECT * 40 | FROM users 41 | WHERE id = $1; 42 | `, [id]); 43 | return { 44 | ...rows[0], 45 | id: rows[0].id.toString(), 46 | emailVerified: rows[0].email_verified, 47 | email: rows[0].email, 48 | }; 49 | }; 50 | 51 | // const getUserByEmail = async (email: string) => { 52 | // const { rows } = await pool.query(`SELECT * FROM users WHERE email = ${email}`); 53 | // return rows[0] 54 | // ? { 55 | // ...rows[0], 56 | // id: rows[0].id.toString(), 57 | // emailVerified: rows[0].email_verified, 58 | // email: rows[0].email, 59 | // } 60 | // : null; 61 | // }; 62 | const getUserByEmail = async (email: string) => { 63 | const { rows } = await pool.query(`SELECT * FROM users WHERE email = $1`, [email]); 64 | return rows[0] 65 | ? { 66 | ...rows[0], 67 | id: rows[0].id.toString(), 68 | emailVerified: rows[0].email_verified, 69 | email: rows[0].email, 70 | } 71 | : null; 72 | }; 73 | 74 | 75 | const getUserByAccount = async ({ 76 | provider, 77 | providerAccountId, 78 | }: { 79 | provider: string; 80 | providerAccountId: string; 81 | }): Promise => { 82 | const { rows } = await pool.query(` 83 | SELECT u.* 84 | FROM users u join accounts a on u.id = a.user_id 85 | WHERE a.provider_id = $1 86 | AND a.provider_account_id = $2`, [provider, providerAccountId]); 87 | const user = rows[0] 88 | ? { 89 | email: rows[0].email, 90 | emailVerified: rows[0].email_verified, 91 | id: rows[0].id, 92 | } 93 | : null; 94 | return user; 95 | }; 96 | 97 | const updateUser = async ( 98 | user: Partial & Pick 99 | ): Promise => { 100 | const { name, email, image, id } = user; 101 | const { rows } = await pool.query(` 102 | UPDATE users 103 | SET name = $1, email = $2}, image = $3 104 | WHERE id = $4 105 | RETURNING id, name, email, image; 106 | `, [name, email, image, id]); 107 | const updatedUser: AdapterUser = { 108 | ...rows[0], 109 | id: rows[0].id.toString(), 110 | emailVerified: rows[0].email_verified, 111 | email: rows[0].email, 112 | }; 113 | return updatedUser; 114 | }; 115 | 116 | const deleteUser = async (userId: string) => { 117 | await pool.query(`DELETE FROM users WHERE id = ${userId}`); 118 | return; 119 | }; 120 | 121 | const createSession = async ({ 122 | sessionToken, 123 | userId, 124 | expires, 125 | }: { 126 | sessionToken: string; 127 | userId: string; 128 | expires: Date; 129 | }): Promise => { 130 | const expiresString = expires.toDateString(); 131 | await pool.query(` 132 | INSERT INTO auth_sessions (user_id, expires, session_token) 133 | VALUES ($1, $2, $3) 134 | `, [userId, expiresString, sessionToken]); 135 | const createdSession: AdapterSession = { 136 | sessionToken, 137 | userId, 138 | expires, 139 | }; 140 | return createdSession; 141 | }; 142 | 143 | const getSessionAndUser = async ( 144 | sessionToken: string 145 | ): Promise<{ session: AdapterSession; user: AdapterUser } | null> => { 146 | const session = await pool.query(` 147 | SELECT * 148 | FROM auth_sessions 149 | WHERE session_token = $1`, [sessionToken]); 150 | const { rows } = await pool.query(` 151 | SELECT * 152 | FROM users 153 | WHERE id = $1`, [session.rows[0].user_id]); 154 | const expiresDate = new Date(session.rows[0].expires); 155 | const sessionAndUser: { session: AdapterSession; user: AdapterUser } = { 156 | session: { 157 | sessionToken: session.rows[0].session_token, 158 | userId: session.rows[0].user_id, 159 | expires: expiresDate, 160 | }, 161 | user: { 162 | id: rows[0].id, 163 | emailVerified: rows[0].email_verified, 164 | email: rows[0].email, 165 | name: rows[0].name, 166 | image: rows[0].image, 167 | }, 168 | }; 169 | 170 | return sessionAndUser; 171 | }; 172 | 173 | const updateSession = async ( 174 | session: Partial & Pick 175 | ): Promise => { 176 | console.log( 177 | "Unimplemented function! updateSession in PostgresAdapter. Session:", 178 | JSON.stringify(session) 179 | ); 180 | return; 181 | }; 182 | 183 | const deleteSession = async (sessionToken: string) => { 184 | await pool.query(` 185 | DELETE FROM auth_sessions 186 | WHERE session_token = $1 187 | `, [sessionToken]); 188 | return; 189 | }; 190 | 191 | const linkAccount = async ( 192 | account: AdapterAccount 193 | ): Promise => { 194 | const { 195 | userId, 196 | provider, 197 | type, 198 | providerAccountId, 199 | refresh_token, 200 | access_token, 201 | expires_at, 202 | token_type, 203 | scope, 204 | id_token 205 | } = account; 206 | 207 | const existingAccount = await pool.query( 208 | ` 209 | SELECT * 210 | FROM accounts 211 | WHERE user_id = $1 AND provider_account_id = $2 212 | `, 213 | [userId, providerAccountId] 214 | ); 215 | 216 | if (existingAccount.rows.length > 0) { 217 | return existingAccount.rows[0]; 218 | } else { 219 | 220 | await pool.query(` 221 | INSERT INTO accounts ( 222 | user_id, 223 | provider_id, 224 | provider_type, 225 | provider_account_id, 226 | refresh_token, 227 | access_token, 228 | expires_at, 229 | token_type, 230 | scope, 231 | id_token 232 | ) 233 | VALUES ( 234 | $1, 235 | $2, 236 | $3, 237 | $4, 238 | $5, 239 | $6, 240 | to_timestamp($7), 241 | $8, 242 | $9, 243 | $10 244 | )`, [userId, provider, type, providerAccountId, refresh_token, access_token, expires_at, token_type, scope, id_token]); 245 | return account; 246 | }; 247 | } 248 | 249 | const unlinkAccount = async ({ 250 | providerAccountId, 251 | provider, 252 | }: { 253 | providerAccountId: Account["providerAccountId"]; 254 | provider: Account["provider"]; 255 | }) => { 256 | await pool.query(` 257 | DELETE FROM accounts 258 | WHERE provider_account_id = $ AND provider_id = $2}`, 259 | [providerAccountId, provider]); 260 | return; 261 | }; 262 | 263 | const createVerificationToken = async ({ 264 | identifier, 265 | expires, 266 | token, 267 | }: VerificationToken): Promise => { 268 | const { rows } = await pool.query(` 269 | INSERT INTO verification_tokens (identifier, token, expires) 270 | VALUES ($1, $2, $3)`, [identifier, token, expires.toString()]); 271 | const createdToken: VerificationToken = { 272 | identifier: rows[0].identifier, 273 | token: rows[0].token, 274 | expires: rows[0].expires, 275 | }; 276 | return createdToken; 277 | }; 278 | 279 | //Return verification token from the database and delete it so it cannot be used again. 280 | const useVerificationToken = async ({ 281 | identifier, 282 | token, 283 | }: { 284 | identifier: string; 285 | token: string; 286 | }) => { 287 | const { rows } = await pool.query(` 288 | SELECT * FROM verification_tokens 289 | WHERE identifier = $1 290 | AND token = $2 AND expires > NOW()`, [identifier, token]); 291 | await pool.query(` 292 | DELETE FROM verification_tokens 293 | WHERE identifier = $1 294 | AND token = $2`, [identifier, token]); 295 | return { 296 | expires: rows[0].expires, 297 | identifier: rows[0].identifier, 298 | token: rows[0].token, 299 | }; 300 | }; 301 | 302 | return { 303 | createUser, 304 | getUser, 305 | updateUser, 306 | getUserByEmail, 307 | getUserByAccount, 308 | deleteUser, 309 | getSessionAndUser, 310 | createSession, 311 | updateSession, 312 | deleteSession, 313 | createVerificationToken, 314 | useVerificationToken, 315 | linkAccount, 316 | unlinkAccount, 317 | }; 318 | } catch (error) { 319 | throw error; 320 | } 321 | } -------------------------------------------------------------------------------- /src/app/api/sqlController/setup.sql: -------------------------------------------------------------------------------- 1 | /* 2 | SET UP SQL SCRIPT: 3 | - All tables necessary for storage in a Postgres Database 4 | */ 5 | 6 | 7 | CREATE TABLE users ( 8 | id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 9 | name VARCHAR(255) NOT NULL, 10 | password VARCHAR(255), 11 | email VARCHAR(255) NOT NULL UNIQUE, 12 | email_verified BOOLEAN DEFAULT false, 13 | port INT, 14 | image TEXT, 15 | created_at TIMESTAMP DEFAULT NOW(), 16 | updated_at TIMESTAMP DEFAULT NOW() 17 | ); 18 | 19 | CREATE TABLE accounts ( 20 | id SERIAL PRIMARY KEY, 21 | user_id UUID NOT NULL REFERENCES users(id), 22 | provider_id VARCHAR(255) NOT NULL, 23 | provider_type VARCHAR(255) NOT NULL, 24 | provider_account_id VARCHAR(255) NOT NULL, 25 | refresh_token TEXT, 26 | access_token TEXT NOT NULL, 27 | expires_at TIMESTAMP WITH TIME ZONE, 28 | token_type VARCHAR(255), 29 | scope TEXT, 30 | id_token TEXT, 31 | session_state TEXT 32 | ); 33 | 34 | CREATE TABLE verification_tokens ( 35 | identifier VARCHAR(255) PRIMARY KEY, 36 | token TEXT NOT NULL, 37 | expires TIMESTAMP WITH TIME ZONE NOT NULL 38 | ); 39 | 40 | CREATE TABLE auth_sessions ( 41 | id SERIAL PRIMARY KEY, 42 | expires TIMESTAMP WITH TIME ZONE NOT NULL, 43 | session_token TEXT NOT NULL, 44 | user_id UUID NOT NULL REFERENCES users(id) 45 | ); -------------------------------------------------------------------------------- /src/app/api/sqlController/sql.ts: -------------------------------------------------------------------------------- 1 | /* 2 | SQL Controller: 3 | - Creates the connection to SQL Database 4 | - Establishes Client for Querying and Release for Ending Connection 5 | */ 6 | 7 | import { Pool, PoolClient } from 'pg'; 8 | 9 | const pg_URI = 'postgres://gymssbhl:nN2Eg1LZKQ-liUJdig1ZIgVNQTJ_5kvc@mahmud.db.elephantsql.com/gymssbhl'; 10 | 11 | const pool = new Pool({ 12 | connectionString: pg_URI 13 | }) 14 | 15 | export default async function connectToDatabase() { 16 | 17 | type sqlFuncs = { 18 | dbClient?: PoolClient, 19 | dbRelease?: () => void 20 | } 21 | 22 | const dbFuncs: sqlFuncs = {}; 23 | 24 | try { 25 | const client = await pool.connect(); 26 | console.log('Connected!'); 27 | 28 | dbFuncs.dbClient = client; // PERSISTS CONNECTION THROUGH MIDDLEWARE 29 | dbFuncs.dbRelease = () => client.release(); // ENDS CONNECTION 30 | 31 | return dbFuncs; 32 | 33 | } catch (error) { 34 | console.log('Error connecting to SQL Database: ', error); 35 | throw error; 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /src/app/contact/card.tsx: -------------------------------------------------------------------------------- 1 | /* Styling of display cards for /contact page */ 2 | 3 | import Link from 'next/link'; 4 | import Image from 'next/image'; 5 | 6 | type cardProps = { 7 | name: string; 8 | key: number; 9 | pfp: string; 10 | linkedin: string; 11 | github: string; 12 | }; 13 | 14 | export default function Card({ name, key, pfp, linkedin, github }: cardProps) { 15 | return ( 16 |
21 | 22 | Image of someone 29 | 30 |

{name}

31 |

32 | Software Engineer 33 |

34 |
35 | 36 | GitHub Logo with a link 43 | 44 | 45 | LinkedIn Logo with a link 52 | 53 |
54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/app/contact/page.tsx: -------------------------------------------------------------------------------- 1 | /* /contact landing page that displays creater contact cards */ 2 | 3 | import Link from 'next/link'; 4 | import Card from './card'; 5 | 6 | export default function Contact() { 7 | const people: string[] = [ 8 | 'Tom Nguyen', 9 | 'Justin Shim', 10 | 'Nitesh Sunku', 11 | 'Donald Twiford', 12 | 'Benson Zhen', 13 | ]; 14 | 15 | const pfps: string[] = [ 16 | '/tom-pfp.jpeg', 17 | '/justin-pfp.jpeg', 18 | '/nitesh-pfp.jpeg', 19 | '/donald-pfp.jpeg', 20 | '/benson-pfp.jpeg', 21 | ]; 22 | 23 | const linkedinUrls: string[] = [ 24 | 'https://www.linkedin.com/in/nguyentomt/', 25 | 'https://www.linkedin.com/in/justinshim/', 26 | 'https://www.linkedin.com/in/niteshsunku/', 27 | 'https://www.linkedin.com/in/donald-twiford-13731a118/', 28 | 'https://www.linkedin.com/in/bensonzhen/', 29 | ]; 30 | 31 | const githubUrls: string[] = [ 32 | 'https://github.com/nguyentomt', 33 | 'https://github.com/slip4k', 34 | 'https://github.com/nsunku99', 35 | 'https://github.com/KrankyKnight', 36 | 'https://github.com/bensonzhen', 37 | ]; 38 | 39 | const cards: JSX.Element[] = people.map((person, idx) => 40 | Card({ 41 | name: person, 42 | key: idx, 43 | pfp: pfps[idx], 44 | linkedin: linkedinUrls[idx], 45 | github: githubUrls[idx], 46 | }) 47 | ); 48 | 49 | return ( 50 |
51 | 52 |
{cards}
53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/Neo/280cf55a762e26a6427ecaf07df2d5f1426234e6/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | background-color: #131314; 7 | /* margin: 0 15.5%; */ 8 | /* margin-top: 150px; */ 9 | overflow-x: hidden; 10 | background: linear-gradient(180deg, 11 | #131314 20%, 12 | rgba(156, 169, 214, 0.747) 300%); 13 | } 14 | 15 | a:hover { 16 | text-decoration: underline; 17 | } 18 | 19 | * { 20 | /* color: whitesmoke; */ 21 | scrollbar-width: auto; 22 | scrollbar-color: #c2c2c2 #131314; 23 | } 24 | 25 | .navline { 26 | height: 10px; 27 | /* margin: 0 auto; */ 28 | /* width: 69%; */ 29 | border-radius: 10px; 30 | background-color: whitesmoke; 31 | } 32 | 33 | 34 | .directoryItem { 35 | cursor: pointer; 36 | } 37 | 38 | .directoryItem:hover { 39 | text-decoration: underline; 40 | transition: .69s; 41 | } 42 | 43 | .footer { 44 | color: whitesmoke; 45 | /* border: 1px solid blue; */ 46 | display: flex; 47 | align-items: center; 48 | justify-content: space-around; 49 | width: 100vw; 50 | height: 15vh; 51 | /* position: absolute; 52 | bottom: -15vh; */ 53 | border-top: 1px solid white; 54 | } 55 | 56 | #content { 57 | width: 70vw; 58 | min-height: 80vh; 59 | /* border: 1px solid white; */ 60 | margin: 2em auto; 61 | } 62 | 63 | #app-header { 64 | padding: 2%; 65 | } 66 | 67 | #app-header_line { 68 | margin: 0 auto; 69 | width: 65vw; 70 | height: 10px; 71 | } 72 | 73 | #app-body_line { 74 | margin-left: -1%; 75 | width: 10px; 76 | height: 65vh; 77 | border-radius: 0 0 10px 10px; 78 | } 79 | 80 | /* ===== Scrollbar CSS ===== */ 81 | 82 | /* Chrome, Edge, and Safari */ 83 | *::-webkit-scrollbar { 84 | width: 8px; 85 | } 86 | 87 | *::-webkit-scrollbar-track { 88 | /* background: #000000; */ 89 | border-radius: 0 0 10px 10px; 90 | } 91 | 92 | *::-webkit-scrollbar-thumb { 93 | background-color: #c2c2c2; 94 | border-radius: 10px; 95 | border: 1px inset #ffffff; 96 | } -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | /* Core layout for website */ 2 | 3 | import './globals.css' 4 | import { Inter } from 'next/font/google' 5 | import NavBar from './NavBar' 6 | import Footer from './Footer' 7 | import Provider from './providers/Provider' 8 | 9 | const inter = Inter({ subsets: ['latin'] }) 10 | 11 | export const metadata = { 12 | title: 'Neo App', 13 | description: 'Technical Search Engine Optimization (SEO) Analyzer', 14 | } 15 | 16 | export default function RootLayout({ 17 | children, 18 | }: { 19 | children: React.ReactNode 20 | }) { 21 | return ( 22 | 23 | 24 | 25 | 26 |
{children}
27 |