diff --git a/.dockerignore b/.dockerignore index 3f2eaf49..b6ca0db2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,3 +7,6 @@ infra storage/* docker-compose.dev.yml dockerfile.dev +dockerfile.handler +dockerfile.worker +Frontend diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index bd35b30e..977ed269 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -39,3 +39,11 @@ jobs: - name: Deploy worker run: | aws ecs update-service --cluster wca-registration --service wca-registration-worker --force-new-deployment + - name: Deploy Frontend + run: | + cd ./Frontend + npm install + npm run build-prod + aws s3 cp dist/bundle.js s3://assets.registration.worldcubeassociation.org/bundle.js + aws s3 cp dist/bundle.css s3://assets.registration.worldcubeassociation.org/bundle.css + aws cloudfront create-invalidation --distribution-id E322K044MBR5FG --paths "/bundle.js" "/bundle.css" --output text diff --git a/.gitignore b/.gitignore index c5925fce..97ee3c96 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ tmp node_modules localstack yarn.lock + +*/package-lock.json diff --git a/Frontend/.eslintrc.js b/Frontend/.eslintrc.js new file mode 100644 index 00000000..573f18d9 --- /dev/null +++ b/Frontend/.eslintrc.js @@ -0,0 +1,18 @@ +const { configure, presets } = require('eslint-kit') + +module.exports = configure({ + mode: 'only-errors', + presets: [ + presets.imports(), + presets.prettier(), + presets.node(), + presets.react(), + ], + extend: { + rules: { + 'import/no-default-export': 'off', + "react/jsx-uses-vars": "error", + "react/jsx-uses-react": "error" + }, + } +}) diff --git a/Frontend/.gitignore b/Frontend/.gitignore new file mode 100644 index 00000000..53c37a16 --- /dev/null +++ b/Frontend/.gitignore @@ -0,0 +1 @@ +dist \ No newline at end of file diff --git a/Frontend/.prettierrc b/Frontend/.prettierrc new file mode 100644 index 00000000..2e4bf83a --- /dev/null +++ b/Frontend/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "quoteProps": "consistent" +} \ No newline at end of file diff --git a/Frontend/README.md b/Frontend/README.md new file mode 100644 index 00000000..53c31a11 --- /dev/null +++ b/Frontend/README.md @@ -0,0 +1,7 @@ +# Frontend +The Frontend is written in React and served by AWS Cloudfront. Whenever you push changes to the frontend +the js bundle is built and the cache is invalidated. + +## How to run + +Go to the root folder and run `docker compose -f docker-compose.dev.yml up` diff --git a/Frontend/dockerfile b/Frontend/dockerfile new file mode 100644 index 00000000..96ceec6d --- /dev/null +++ b/Frontend/dockerfile @@ -0,0 +1,12 @@ +# Set the base image to the official Node.js image +FROM node:18 as build + +# Create a working directory for the app +WORKDIR /app + +# Copy the package.json and package-lock.json files to the working directory +COPY package*.json ./ +COPY index.html ./ + +# Install dependencies using npm +RUN npm install diff --git a/Frontend/index.html b/Frontend/index.html new file mode 100644 index 00000000..5888051b --- /dev/null +++ b/Frontend/index.html @@ -0,0 +1,19 @@ + + + + + + WCA Registration Test Page + + + + + +
+ + + diff --git a/Frontend/package.json b/Frontend/package.json new file mode 100644 index 00000000..1814097c --- /dev/null +++ b/Frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "wca-registration-frontend", + "version": "1.0.0", + "description": "The Frontend for the WCA Registration service", + "repository": { + "type": "git", + "url": "git+https://github.com/thewca/wca-registration.git" + }, + "author": "WCA Software Team", + "license": "GPL-3.0-or-later", + "bugs": { + "url": "https://github.com/thewca/wca-registration/issues" + }, + "homepage": "https://github.com/thewca/wca-registration/Frontend/README.md", + "scripts": { + "build-dev": "API_URL=\"http://localhost:3001\" node src/build.js", + "build-prod": "API_URL=\"https://registration.worldcubeassociation.org\" node src/build.js", + "watch": "node src/watch.mjs", + "lint": "eslint src --ext .js,.jsx,.ts,.tsx", + "lint:fix": "eslint src --ext .js,.jsx,.ts,.tsx --fix" + }, + "dependencies": { + "esbuild": "^0.17.19", + "esbuild-scss-modules-plugin": "^1.1.1", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "eslint": "^8.40.0", + "eslint-kit": "^8.5.0", + "prettier": "^2.8.8" + } +} diff --git a/Frontend/src/api/helper/backend_fetch.js b/Frontend/src/api/helper/backend_fetch.js new file mode 100644 index 00000000..bbae250f --- /dev/null +++ b/Frontend/src/api/helper/backend_fetch.js @@ -0,0 +1,19 @@ +export default async function backendFetch(route, method, body = {}) { + try { + let init = {} + if (method !== 'GET') { + init = { + method, + body, + } + } + const response = await fetch(`${process.env.API_URL}/${route}`, init) + + if (response.ok) { + return await response.json() + } + return { error: response.statusText, statusCode: response.status } + } catch (error) { + return { error, statusCode: 500 } + } +} diff --git a/Frontend/src/api/registration/delete/delete_registration.js b/Frontend/src/api/registration/delete/delete_registration.js new file mode 100644 index 00000000..46f32ffe --- /dev/null +++ b/Frontend/src/api/registration/delete/delete_registration.js @@ -0,0 +1,9 @@ +import backendFetch from '../../helper/backend_fetch' + +export default async function deleteRegistration(competitorID, competitionID) { + const formData = new FormData() + formData.append('competitor_id', competitorID) + formData.append('competition_id', competitionID) + + return backendFetch('/register', 'DELETE', formData) +} diff --git a/Frontend/src/api/registration/get/get_registrations.js b/Frontend/src/api/registration/get/get_registrations.js new file mode 100644 index 00000000..459db2fd --- /dev/null +++ b/Frontend/src/api/registration/get/get_registrations.js @@ -0,0 +1,5 @@ +import backendFetch from '../../helper/backend_fetch' + +export default async function getRegistrations(competitionID) { + return backendFetch(`/registrations?competition_id=${competitionID}`, 'GET') +} diff --git a/Frontend/src/api/registration/patch/update_registration.js b/Frontend/src/api/registration/patch/update_registration.js new file mode 100644 index 00000000..9a64cecd --- /dev/null +++ b/Frontend/src/api/registration/patch/update_registration.js @@ -0,0 +1,14 @@ +import backendFetch from '../../helper/backend_fetch' + +export default async function updateRegistration( + competitorID, + competitionID, + status +) { + const formData = new FormData() + formData.append('competitor_id', competitorID) + formData.append('competition_id', competitionID) + formData.append('status', status) + + return backendFetch('/register', 'PATCH', formData) +} diff --git a/Frontend/src/api/registration/post/submit_registration.js b/Frontend/src/api/registration/post/submit_registration.js new file mode 100644 index 00000000..06893d53 --- /dev/null +++ b/Frontend/src/api/registration/post/submit_registration.js @@ -0,0 +1,14 @@ +import backendFetch from '../../helper/backend_fetch' + +export default async function submitEventRegistration( + competitorId, + competitionId, + events +) { + const formData = new FormData() + formData.append('competitor_id', competitorId) + formData.append('competition_id', competitionId) + events.forEach((eventId) => formData.append('event_ids[]', eventId)) + + return backendFetch('/register', 'POST', formData) +} diff --git a/Frontend/src/build.js b/Frontend/src/build.js new file mode 100644 index 00000000..3ab6c3f6 --- /dev/null +++ b/Frontend/src/build.js @@ -0,0 +1,21 @@ +const esbuild = require('esbuild') +const process = require('process') + +esbuild + .build({ + entryPoints: ['src/index.jsx'], + bundle: true, + outfile: 'dist/bundle.js', + jsxFactory: 'React.createElement', + jsxFragment: 'React.Fragment', + plugins: [ + require('esbuild-scss-modules-plugin').ScssModulesPlugin({ + inject: true, + minify: true, + }), + ], + define: { + 'process.env.API_URL': `"${process.env.API_URL}"`, + }, + }) + .catch(() => process.exit(1)) diff --git a/Frontend/src/index.jsx b/Frontend/src/index.jsx new file mode 100644 index 00000000..f9e328cf --- /dev/null +++ b/Frontend/src/index.jsx @@ -0,0 +1,9 @@ +import { createRoot } from 'react-dom/client' +import App from './register/pages' + +// Clear the existing HTML content +document.body.innerHTML = '
' + +// Render your React component instead +const root = createRoot(document.querySelector('#app')) +root.render(App()) diff --git a/Frontend/src/register/components/EventSelection.jsx b/Frontend/src/register/components/EventSelection.jsx new file mode 100644 index 00000000..6f9d923d --- /dev/null +++ b/Frontend/src/register/components/EventSelection.jsx @@ -0,0 +1,34 @@ +import React from 'react' +import styles from './panel.module.scss' + +function toggleElementFromArray(arr, element) { + const index = arr.indexOf(element) + if (index !== -1) { + arr.splice(index, 1) + } else { + arr.push(element) + } + return arr +} + +const EVENTS = ['3x3', '4x4'] + +export default function EventSelection({ events, setEvents }) { + return ( +
+ {EVENTS.map((wca_event) => ( + + ))} +
+ ) +} diff --git a/Frontend/src/register/components/RegistrationList.jsx b/Frontend/src/register/components/RegistrationList.jsx new file mode 100644 index 00000000..5e78b7a6 --- /dev/null +++ b/Frontend/src/register/components/RegistrationList.jsx @@ -0,0 +1,101 @@ +import React, { useState } from 'react' +import deleteRegistration from '../../api/registration/delete/delete_registration' +import getRegistrations from '../../api/registration/get/get_registrations' +import updateRegistration from '../../api/registration/patch/update_registration' +import styles from './list.module.scss' +import StatusDropdown from './StatusDropdown' + +function RegistrationRow({ + competitorId, + eventIDs, + serverStatus, + competitionID, + setRegistrationList, + registrationList, +}) { + const [status, setStatus] = useState(serverStatus) + + return ( + + {competitorId} + {eventIDs.join(',')} + + + + + + + + + + + ) +} + +export default function RegistrationList() { + const [competitionID, setCompetitionID] = useState('HessenOpen2023') + const [registrationList, setRegistrationList] = useState([]) + return ( +
+ + + + + + + + + + + + + + {registrationList.map((registration) => { + return ( + + ) + })} + +
Competitor Events Status Apply Changes Delete
+
+ ) +} diff --git a/Frontend/src/register/components/RegistrationPanel.jsx b/Frontend/src/register/components/RegistrationPanel.jsx new file mode 100644 index 00000000..34e96e33 --- /dev/null +++ b/Frontend/src/register/components/RegistrationPanel.jsx @@ -0,0 +1,41 @@ +import React, { useState } from 'react' +import submitEventRegistration from '../../api/registration/post/submit_registration' +import EventSelection from './EventSelection' +import styles from './panel.module.scss' + +export default function RegistrationPanel() { + const [competitorID, setCompetitorID] = useState('2012ICKL01') + const [competitionID, setCompetitionID] = useState('HessenOpen2023') + const [events, setEvents] = useState([]) + + return ( +
+ + + + +
+ ) +} diff --git a/Frontend/src/register/components/StatusDropdown.jsx b/Frontend/src/register/components/StatusDropdown.jsx new file mode 100644 index 00000000..67180800 --- /dev/null +++ b/Frontend/src/register/components/StatusDropdown.jsx @@ -0,0 +1,12 @@ +import React from 'react' + +export default function StatusDropdown({ status, setStatus }) { + const options = ['waiting', 'accepted'] + return ( + + ) +} diff --git a/Frontend/src/register/components/list.module.scss b/Frontend/src/register/components/list.module.scss new file mode 100644 index 00000000..71e436b3 --- /dev/null +++ b/Frontend/src/register/components/list.module.scss @@ -0,0 +1,7 @@ +.list { + display: flex; + flex-direction: column; + align-items: center; + margin-top: 24%; + width: 50vw; +} \ No newline at end of file diff --git a/Frontend/src/register/components/panel.module.scss b/Frontend/src/register/components/panel.module.scss new file mode 100644 index 00000000..da532aed --- /dev/null +++ b/Frontend/src/register/components/panel.module.scss @@ -0,0 +1,11 @@ +.panel{ + display: flex; + flex-direction: column; + align-items: center; + margin-top: 24%; + width: 50vw; +} + +.events{ + display: flex; +} \ No newline at end of file diff --git a/Frontend/src/register/pages/index.jsx b/Frontend/src/register/pages/index.jsx new file mode 100644 index 00000000..17a1a8d9 --- /dev/null +++ b/Frontend/src/register/pages/index.jsx @@ -0,0 +1,13 @@ +import React from 'react' +import RegistrationList from '../components/RegistrationList' +import RegistrationPanel from '../components/RegistrationPanel' +import styles from './index.module.scss' + +export default function App() { + return ( +
+ + +
+ ) +} diff --git a/Frontend/src/register/pages/index.module.scss b/Frontend/src/register/pages/index.module.scss new file mode 100644 index 00000000..728d637d --- /dev/null +++ b/Frontend/src/register/pages/index.module.scss @@ -0,0 +1,5 @@ +.container { + display: flex; + height: 100vh; + flex-direction: row; +} \ No newline at end of file diff --git a/Frontend/src/watch.mjs b/Frontend/src/watch.mjs new file mode 100644 index 00000000..8f0292af --- /dev/null +++ b/Frontend/src/watch.mjs @@ -0,0 +1,29 @@ +import * as esbuild from 'esbuild'; +import {ScssModulesPlugin} from "esbuild-scss-modules-plugin"; + +const context = await esbuild.context({ + entryPoints: ['src/index.jsx'], + bundle: true, + outfile: 'dist/bundle.js', + jsxFactory: 'React.createElement', + jsxFragment: 'React.Fragment', + plugins: [ + ScssModulesPlugin({ + inject: true, + minify: true, + }), + ], + define: { + 'process.env.API_URL': '"http://localhost:3001"', + }, +}) + +// Enable watch mode +await context.watch() + +// Enable serve mode +await context.serve({ + port: 3000, + servedir: "." +}) + diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index b152a110..b435bbeb 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -81,6 +81,20 @@ services: networks: - wca-registration + # Frontend + frontend: + build: + context: Frontend + ports: + - "3002:3000" + tty: + true + command: bash -c "npm run watch" + networks: + - wca-registration + volumes: + - ./Frontend/src:/app/src + volumes: gems_volume_handler: driver: local diff --git a/infra/frontend/main.tf b/infra/frontend/main.tf new file mode 100644 index 00000000..7f136f5d --- /dev/null +++ b/infra/frontend/main.tf @@ -0,0 +1,20 @@ +resource "aws_s3_bucket" "this" { + bucket = var.bucket_name + tags = { + "Name" = var.bucket_name + } +} + +module "cdn" { + source = "cloudposse/cloudfront-s3-cdn/aws" + # Cloud Posse recommends pinning every module to a specific version + # version = "x.x.x" + + origin_bucket = aws_s3_bucket.this.id + s3_access_logging_enabled = false + logging_enabled = false + + name = "cdn" + stage = "prod" + namespace = "wca-registration" +} diff --git a/infra/frontend/variables.tf b/infra/frontend/variables.tf new file mode 100644 index 00000000..a40f9e2d --- /dev/null +++ b/infra/frontend/variables.tf @@ -0,0 +1,4 @@ +variable "bucket_name" { + type = string + default = "assets.registration.worldcubeassociation.org" +} diff --git a/infra/main.tf b/infra/main.tf index e82981c7..8811089e 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -42,3 +42,6 @@ module "worker" { depends_on = [module.shared_resources] } +module "frontend" { + source = "./frontend" +}