diff --git a/.github/workflows/ci_cd.yaml b/.github/workflows/ci_cd.yaml index f603dee..d704a4e 100644 --- a/.github/workflows/ci_cd.yaml +++ b/.github/workflows/ci_cd.yaml @@ -49,6 +49,9 @@ jobs: infra: paths: - 'apps/infra/**' + meet: + paths: + - 'apps/meet/**' miniextensions-proxy: paths: - 'apps/miniextensions-proxy/**' @@ -63,7 +66,7 @@ jobs: if: ${{ github.ref == 'refs/heads/master' && needs.ci.outputs.should_skip != 'true' || !fromJSON(needs.ci.outputs.paths_result).infra.should_skip }} runs-on: ubuntu-latest concurrency: - group: ${{ github.job }} + group: cd_infra timeout-minutes: 10 steps: - name: Checkout ${{ github.sha }} @@ -94,7 +97,7 @@ jobs: if: ${{ github.ref == 'refs/heads/master' && needs.ci.outputs.should_skip != 'true' || !fromJSON(needs.ci.outputs.paths_result).frontend-example.should_skip }} runs-on: ubuntu-latest concurrency: - group: ${{ github.job }} + group: cd_frontend-example timeout-minutes: 10 steps: - name: Checkout ${{ github.sha }} @@ -119,12 +122,42 @@ jobs: - name: Deploy run: npm run deploy:prod --workspace apps/frontend-example + cd_meet: + needs: ci + if: ${{ github.ref == 'refs/heads/master' && needs.ci.outputs.should_skip != 'true' || !fromJSON(needs.ci.outputs.paths_result).meet.should_skip }} + runs-on: ubuntu-latest + concurrency: + group: cd_meet + timeout-minutes: 10 + steps: + - name: Checkout ${{ github.sha }} + uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org/ + - name: Install NPM dependencies + run: npm ci + + - name: Configure for deployment + run: | + docker login https://sjc.vultrcr.com/bluedot -u dbaa58f3-01f1-4fcc-9c14-93cc28f524e0 -p $VULTR_CONTAINER_REGISTRY_PASSWORD + + mkdir -p ~/.kube + echo "$K8S_KUBECONFIG" > ~/.kube/config + env: + K8S_KUBECONFIG: ${{ secrets.K8S_KUBECONFIG }} + VULTR_CONTAINER_REGISTRY_PASSWORD: ${{ secrets.VULTR_CONTAINER_REGISTRY_PASSWORD }} + - name: Deploy + run: npm run deploy:prod --workspace apps/meet + cd_miniextensions-proxy: needs: ci if: ${{ github.ref == 'refs/heads/master' && needs.ci.outputs.should_skip != 'true' || !fromJSON(needs.ci.outputs.paths_result).miniextensions-proxy.should_skip }} runs-on: ubuntu-latest concurrency: - group: ${{ github.job }} + group: cd_miniextensions-proxy timeout-minutes: 10 steps: - name: Checkout ${{ github.sha }} diff --git a/apps/backend/src/db/client.ts b/apps/backend/src/db/client.ts index cad11f0..d1b4a8c 100644 --- a/apps/backend/src/db/client.ts +++ b/apps/backend/src/db/client.ts @@ -2,7 +2,6 @@ import { Pool } from 'pg'; import { Kysely, PostgresDialect } from 'kysely'; // All these ignores are to allow this to pass in CI, where we don't have a database to generate the files with // If we do more with backend, we should fix this properly by having CI spin up a database -// eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore // eslint-disable-next-line import/extensions import Database from './generated/Database'; diff --git a/apps/frontend-example/src/pages/_app.tsx b/apps/frontend-example/src/pages/_app.tsx index 8b517da..0aa667c 100644 --- a/apps/frontend-example/src/pages/_app.tsx +++ b/apps/frontend-example/src/pages/_app.tsx @@ -1,10 +1,16 @@ -import dynamic from 'next/dynamic'; import '../globals.css'; import type { AppProps } from 'next/app'; +import Head from 'next/head'; +import dynamic from 'next/dynamic'; const App: React.FC = ({ Component, pageProps }: AppProps) => { return ( - + <> + + frontend-example + + + ); }; diff --git a/apps/frontend-example/tailwind.config.ts b/apps/frontend-example/tailwind.config.ts index 1194b81..64f999b 100644 --- a/apps/frontend-example/tailwind.config.ts +++ b/apps/frontend-example/tailwind.config.ts @@ -15,7 +15,7 @@ export default { dark: '#002199', darker: '#00114D', }, - agisf: { + aisf: { lighter: '#E6B3FF', light: '#D680FF', normal: '#C64EFF', diff --git a/apps/infra/Pulumi.prod.yaml b/apps/infra/Pulumi.prod.yaml index f503fa9..cf84368 100644 --- a/apps/infra/Pulumi.prod.yaml +++ b/apps/infra/Pulumi.prod.yaml @@ -1,8 +1,12 @@ encryptionsalt: v1:1DwWI+bI55I=:v1:tpqziDy1t6GntGld:l3IA3E6yuXwTfNlPX8Lxe59IqDG7QA== config: + infra:airtablePat: + secure: v1:xxnGOjci0H2TTfIY:p5/SIY+vels8uuUTUCJYRW3JIIYJNF9Jmr1m8usCqYgjU073BMKjQ+uq3dUb42f1iNeOs0A124ZIzDjxv9f1cJNaOd8gfvAxQf1oA1WC/A/CqnHyoNFIG6zpE1C0gNtu+u8= + infra:alertsSlackBotToken: + secure: v1:KHo0xEvyljpu6RQu:FxDE6U4NfjkQTTCE+yLJKpF2tnjiNqGM4eWfgnSMgD+3RKyicTHqUuVKYyEQdNYj9YZkokYeD6X0eB277AYJHQUV3ZhbrrO/vA== infra:containerRegistryDockerConfigJson: secure: v1:lycjX7rEaALQ9R/z:sZY2d07ja/BvKImmficAenAbHtUZ/fRgnq1VUrD9whc4PEwv8RM952po7asuECsQHPF7/nSAheMWH4dk2n039ylID933zQ9lMSh9AbDP5hsw6Td+X+KSwyYBZlSx6RdYfzAf8F7dRk6Rd+varZXql+rKpwfhpcXoEd9OMHR/niMQIBOQk5mUqc/m2FUMkWptCYMJpfYUQu4x9oqA52hG39Ojbdr7NDf+ZiedXWIJFmniCTdOZRNUWDgnkXI2fGkPI0yr1O8cr68aS2WmrvtVYDfheaKJZd9kZ4avug== - infra:dbPassword: - secure: v1:ERZRodNiiPzZWbC2:sG0lmdI7kUxxeD3krcFFzUg/SOa4bsXBwigCyoh8xAZvNFFopFPeSAR+TMUyrfE/YHt+XDHHKFuAm319k2j+gL+aYYtP3g83PhTOojEQSJI= + infra:meetZoomClientSecret: + secure: v1:jPYJIQX1CoHF/Zmm:KTKen2BLFJ2UpCuIOXE5kdUh0OynxG/UbVbTD4h0Ft5X2UFFRYPp1iSVGd8Rxd+o vultr:apiKey: secure: v1:V4H9p6vQNMJM4KEQ:G/T2vskLJtn5ZtO9Ry7TfstCV0lwGmCQIwb6twGs9j9VJq5FWG249dYKg/RuJFOXl6eDZg== diff --git a/apps/infra/README.md b/apps/infra/README.md index 7a63be9..939a215 100644 --- a/apps/infra/README.md +++ b/apps/infra/README.md @@ -21,7 +21,30 @@ aws_secret_access_key= Get `passphrase.prod.txt` from 1Password. -## Manual connections +## Tasks + +### Adding a new service + +TODO + +### Adding a secret + +We manage secrets with [Pulumi secrets](https://www.pulumi.com/learn/building-with-pulumi/secrets/). In general, we pass in secrets via Pulumi as environment variables to containers. + +To add a secret, run: + +```bash +# key should be a camelCase identifier e.g. meetZoomClientSecret +npm run config:secret +``` + +It'll prompt you for a value, and then update [Pulumi.prod.yaml](./Pulumi.prod.yaml). + +If you want to use your secret as an environment variable, add it to the `toK8s` array in [secrets.ts](./src/k8s/secrets.ts). You can then use it as `envVarSources.key` in other files. + +If you want to use your secret 'raw', import config from [config.ts](./src/config.ts) and then call `config.requireSecret('key')`. + +### Connecting with kubectl ```bash PULUMI_CONFIG_PASSPHRASE_FILE=passphrase.prod.txt pulumi stack output --show-secrets k8sConfig > kubeconfig.json diff --git a/apps/infra/src/config.ts b/apps/infra/src/config.ts index 3561f9c..76c1a6d 100644 --- a/apps/infra/src/config.ts +++ b/apps/infra/src/config.ts @@ -1,6 +1,6 @@ import { Config } from '@pulumi/pulumi'; -const config = new Config(); +export const config = new Config(); export const vultrRegion = config.get('vultrRegion') || 'ams'; @@ -10,6 +10,3 @@ export const k8sNodeCount = config.getNumber('k8sNodeCount') || 1; // VPS plan to use for K8s cluster nodes. // See https://www.vultr.com/api/#tag/plans/operation/list-plans export const k8sVpsPlan = config.get('k8sVpsPlan') || 'vc2-1c-2gb'; - -export const containerRegistryDockerConfigJson = config.requireSecret('containerRegistryDockerConfigJson'); -export const dbPassword = config.requireSecret('dbPassword'); diff --git a/apps/infra/src/k8s/containerRegistry.ts b/apps/infra/src/k8s/containerRegistry.ts deleted file mode 100644 index 7c111e7..0000000 --- a/apps/infra/src/k8s/containerRegistry.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as k8s from '@pulumi/kubernetes'; -import { provider } from './provider'; -import { containerRegistryDockerConfigJson } from '../config'; - -export const containerRegistrySecret = new k8s.core.v1.Secret('vultr-cr-secret', { - metadata: { - name: 'vultr-cr-credentials', - }, - data: { - '.dockerconfigjson': containerRegistryDockerConfigJson, - }, - type: 'kubernetes.io/dockerconfigjson', -}, { provider }); diff --git a/apps/infra/src/k8s/index.ts b/apps/infra/src/k8s/index.ts index ce2a2e4..0425583 100644 --- a/apps/infra/src/k8s/index.ts +++ b/apps/infra/src/k8s/index.ts @@ -1,4 +1,4 @@ -import './containerRegistry'; +import './secrets'; import './ingress'; import './certManager'; import './services'; diff --git a/apps/infra/src/k8s/secrets.ts b/apps/infra/src/k8s/secrets.ts new file mode 100644 index 0000000..276e5b0 --- /dev/null +++ b/apps/infra/src/k8s/secrets.ts @@ -0,0 +1,36 @@ +import * as k8s from '@pulumi/kubernetes'; +import { core } from '@pulumi/kubernetes/types/input'; +import { provider } from './provider'; +import { config } from '../config'; + +// These Pulumi secrets will be converted to K8s secrets automatically +const toK8s = [ + 'airtablePat', + 'alertsSlackBotToken', + 'meetZoomClientSecret', +] as const; + +export const envVarSources = toK8s.reduce((obj, key) => { + const resource = new k8s.core.v1.Secret(`${key.toLowerCase()}-secret`, { + metadata: { + name: `${key.toLowerCase()}-secret`, + }, + stringData: { + value: config.requireSecret(key), + }, + }, { provider }); + + // eslint-disable-next-line no-param-reassign + obj[key] = { secretKeyRef: { name: resource.metadata.name, key: 'value' } }; + return obj; +}, {} as Record); + +export const containerRegistrySecret = new k8s.core.v1.Secret('vultr-cr-secret', { + metadata: { + name: 'vultr-cr-credentials', + }, + data: { + '.dockerconfigjson': config.requireSecret('containerRegistryDockerConfigJson'), + }, + type: 'kubernetes.io/dockerconfigjson', +}, { provider }); diff --git a/apps/infra/src/k8s/serviceDefinitions.ts b/apps/infra/src/k8s/serviceDefinitions.ts index f3355f5..4db6a92 100644 --- a/apps/infra/src/k8s/serviceDefinitions.ts +++ b/apps/infra/src/k8s/serviceDefinitions.ts @@ -1,5 +1,5 @@ import { core } from '@pulumi/kubernetes/types/input'; -import { containerRegistrySecret } from './containerRegistry'; +import { containerRegistrySecret, envVarSources } from './secrets'; // TODO: pin the external versions export const services: ServiceDefinition[] = [ @@ -38,6 +38,25 @@ export const services: ServiceDefinition[] = [ }, hosts: ['forms.bluedot.org'], }, + { + name: 'bluedot-meet', + targetPort: 8080, + spec: { + containers: [{ + name: 'bluedot-meet', + image: 'sjc.vultrcr.com/bluedot/bluedot-meet:latest', + env: [ + { name: 'AIRTABLE_PERSONAL_ACCESS_TOKEN', valueFrom: envVarSources.airtablePat }, + { name: 'NEXT_PUBLIC_ZOOM_CLIENT_ID', value: 'lX1NBglbQWO2ERYSS1xdfA' }, + { name: 'ZOOM_CLIENT_SECRET', valueFrom: envVarSources.meetZoomClientSecret }, + { name: 'ALERTS_SLACK_CHANNEL_ID', value: 'C04SAGM4FN1' /* #tech-prod-alerts */ }, + { name: 'ALERTS_SLACK_BOT_TOKEN', valueFrom: envVarSources.alertsSlackBotToken }, + ], + }], + imagePullSecrets: [{ name: containerRegistrySecret.metadata.name }], + }, + hosts: ['meet.bluedot.org'], + }, // { // name: 'bluedot-bubble-proxy', // targetPort: 80, diff --git a/apps/meet/.env.local.template b/apps/meet/.env.local.template new file mode 100644 index 0000000..01053dd --- /dev/null +++ b/apps/meet/.env.local.template @@ -0,0 +1,13 @@ +# Airtable: https://support.airtable.com/docs/creating-and-using-api-keys-and-access-tokens +AIRTABLE_PERSONAL_ACCESS_TOKEN= + +# Zoom +NEXT_PUBLIC_ZOOM_CLIENT_ID=lX1NBglbQWO2ERYSS1xdfA +ZOOM_CLIENT_SECRET= + +# BlueDot Impact Slack, #tech-dev-alerts +ALERTS_SLACK_CHANNEL_ID=C04SFUECECU +# For (local) BlueBot see https://airtable.com/appnNmNoNMB6crg6I/tbllthJ2YSPDsKWCt/viwfjWLvplq6Xw93D/recqurdJTPWzm5y4W +# For (prod) BlueBot see https://api.slack.com/apps/A04PV2GAQRY/install-on-team +# Starts 'xoxb-' +ALERTS_SLACK_BOT_TOKEN= diff --git a/apps/meet/.env.test b/apps/meet/.env.test new file mode 100644 index 0000000..d10222a --- /dev/null +++ b/apps/meet/.env.test @@ -0,0 +1,10 @@ +# Airtable +AIRTABLE_PERSONAL_ACCESS_TOKEN=FAKE_TOKEN + +# Zoom +NEXT_PUBLIC_ZOOM_CLIENT_ID=FAKE_CLIENT_ID +ZOOM_CLIENT_SECRET=FAKE_CLIENT_SECRET + +# Slack +ALERTS_SLACK_CHANNEL_ID=C04SFUECECU +ALERTS_SLACK_BOT_TOKEN=FAKE_TOKEN diff --git a/apps/meet/Dockerfile b/apps/meet/Dockerfile new file mode 100644 index 0000000..3a42323 --- /dev/null +++ b/apps/meet/Dockerfile @@ -0,0 +1,25 @@ +FROM node:lts-alpine@sha256:7e227295e96f5b00aa79555ae166f50610940d888fc2e321cf36304cbd17d7d6 AS base + +RUN apk update && apk add --no-cache dumb-init + +WORKDIR /app + +ENV NODE_ENV production +ENV NEXT_TELEMETRY_DISABLED 1 + +# Set the correct permission for prerender cache +RUN mkdir .next +RUN chown node:node .next + +ARG APP_NAME +ENV APP_NAME=${APP_NAME} + +COPY --chown=node:node .next/standalone ./ +COPY --chown=node:node public ./apps/${APP_NAME}/public +COPY --chown=node:node .next/static ./apps/${APP_NAME}/.next/static + +USER node + +EXPOSE 8080 + +CMD HOSTNAME="0.0.0.0" PORT="8080" dumb-init node ./apps/${APP_NAME}/server.js diff --git a/apps/meet/next-env.d.ts b/apps/meet/next-env.d.ts new file mode 100644 index 0000000..4f11a03 --- /dev/null +++ b/apps/meet/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/meet/next.config.js b/apps/meet/next.config.js new file mode 100644 index 0000000..607f8d2 --- /dev/null +++ b/apps/meet/next.config.js @@ -0,0 +1,41 @@ +/** @type {import('next').NextConfig} */ +module.exports = { + transpilePackages: ['@bluedot/ui'], + reactStrictMode: true, + output: 'standalone', + + // We already run eslint as a separate step + eslint: { + ignoreDuringBuilds: true, + }, + + poweredByHeader: false, + headers: async () => { + return [ + // See https://developers.zoom.us/docs/meeting-sdk/web/sharedarraybuffer/ + { + source: '/', + headers: [ + { + key: 'Cross-Origin-Embedder-Policy', + value: 'require-corp', + }, + { + key: 'Cross-Origin-Opener-Policy', + value: 'same-origin', + }, + ], + }, + { + source: '/:path*', + headers: [ + { + key: 'X-Bluedot-Version', + // eslint-disable-next-line turbo/no-undeclared-env-vars + value: process.env.VERSION_TAG || 'unknown', + }, + ], + }, + ]; + }, +}; diff --git a/apps/meet/package.json b/apps/meet/package.json new file mode 100644 index 0000000..33792e2 --- /dev/null +++ b/apps/meet/package.json @@ -0,0 +1,47 @@ +{ + "name": "@bluedot/meet", + "version": "1.0.0", + "private": true, + "scripts": { + "postinstall": "shx cp -n .env.local.template .env.local", + "start": "next dev -p 8000", + "build": "next build", + "lint": "eslint .", + "test": "vitest --run", + "test:watch": "vitest", + "deploy:prod": "tools/deployDocker.sh" + }, + "dependencies": { + "@bluedot/ui": "*", + "@types/jsonwebtoken": "^9.0.6", + "@zoom/meetingsdk": "^3.5.2", + "airtable-ts": "^1.0.0", + "axios": "^1.6.8", + "axios-hooks": "^5.0.2", + "clsx": "^2.1.0", + "http-errors": "^2.0.0", + "jsonwebtoken": "^9.0.2", + "next": "^14.0.3", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@bluedot/eslint-config": "*", + "@bluedot/typescript-config": "*", + "@testing-library/react": "^15.0.2", + "@types/node": "^20.10.3", + "@types/react": "^18.0.22", + "@types/react-dom": "^18.0.7", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.19", + "dotenv": "^16.4.5", + "eslint": "^8.53.0", + "happy-dom": "^14.7.1", + "node-mocks-http": "^1.14.1", + "postcss": "^8.4.38", + "shx": "^0.3.4", + "tailwindcss": "^3.4.3", + "typescript": "^5.3.2", + "vitest": "^1.5.0" + } +} diff --git a/apps/meet/postcss.config.js b/apps/meet/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/apps/meet/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/meet/public/fonts/RecklessNeue-Light.woff2 b/apps/meet/public/fonts/RecklessNeue-Light.woff2 new file mode 100644 index 0000000..dce4437 Binary files /dev/null and b/apps/meet/public/fonts/RecklessNeue-Light.woff2 differ diff --git a/apps/meet/public/fonts/RecklessNeue-LightItalic.woff2 b/apps/meet/public/fonts/RecklessNeue-LightItalic.woff2 new file mode 100644 index 0000000..176d66e Binary files /dev/null and b/apps/meet/public/fonts/RecklessNeue-LightItalic.woff2 differ diff --git a/apps/meet/public/fonts/Roobert-Bold.woff2 b/apps/meet/public/fonts/Roobert-Bold.woff2 new file mode 100644 index 0000000..6bcc13c Binary files /dev/null and b/apps/meet/public/fonts/Roobert-Bold.woff2 differ diff --git a/apps/meet/public/fonts/Roobert-Light.woff2 b/apps/meet/public/fonts/Roobert-Light.woff2 new file mode 100644 index 0000000..183bcc9 Binary files /dev/null and b/apps/meet/public/fonts/Roobert-Light.woff2 differ diff --git a/apps/meet/public/fonts/Roobert-Regular.woff2 b/apps/meet/public/fonts/Roobert-Regular.woff2 new file mode 100644 index 0000000..78791db Binary files /dev/null and b/apps/meet/public/fonts/Roobert-Regular.woff2 differ diff --git a/apps/meet/src/base.css b/apps/meet/src/base.css new file mode 100644 index 0000000..e4fe255 --- /dev/null +++ b/apps/meet/src/base.css @@ -0,0 +1,384 @@ +/* +Base styles to reset CSS, scoped to tw-preflight container +Derived from https://unpkg.com/tailwindcss@3.3.3/src/css/preflight.css +Also see https://github.com/tailwindlabs/tailwindcss/discussions/7050 +*/ + +/* +1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) +2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) +*/ + +:where(.tw-preflight) *, +:where(.tw-preflight) ::before, +:where(.tw-preflight) ::after { + box-sizing: border-box; /* 1 */ + border-width: 0; /* 2 */ + border-style: solid; /* 2 */ + border-color: theme('borderColor.DEFAULT', currentColor); /* 2 */ +} + +:where(.tw-preflight) ::before, +:where(.tw-preflight) ::after { + --tw-content: ''; +} + +/* +1. Use a consistent sensible line-height in all browsers. +2. Prevent adjustments of font size after orientation changes in iOS. +3. Use a more readable tab size. +4. Use the user's configured `sans` font-family by default. +5. Use the user's configured `sans` font-feature-settings by default. +6. Use the user's configured `sans` font-variation-settings by default. +*/ + +html { + line-height: 1.5; /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ + -moz-tab-size: 4; /* 3 */ + tab-size: 4; /* 3 */ + font-family: theme('fontFamily.sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); /* 4 */ + font-feature-settings: theme('fontFamily.sans[1].fontFeatureSettings', normal); /* 5 */ + font-variation-settings: theme('fontFamily.sans[1].fontVariationSettings', normal); /* 6 */ +} + +/* +1. Remove the margin in all browsers. +2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + margin: 0; /* 1 */ + line-height: inherit; /* 2 */ +} + +/* +1. Add the correct height in Firefox. +2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) +3. Ensure horizontal rules are visible by default. +*/ + +:where(.tw-preflight) hr { + height: 0; /* 1 */ + color: inherit; /* 2 */ + border-top-width: 1px; /* 3 */ +} + +/* +Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +:where(.tw-preflight) abbr:where([title]) { + text-decoration: underline dotted; +} + +/* +Remove the default font size and weight for headings. +*/ + +:where(.tw-preflight) h1, +:where(.tw-preflight) h2, +:where(.tw-preflight) h3, +:where(.tw-preflight) h4, +:where(.tw-preflight) h5, +:where(.tw-preflight) h6 { + font-size: inherit; + font-weight: inherit; +} + +/* +Reset links to optimize for opt-in styling instead of opt-out. +*/ + +:where(.tw-preflight) a { + color: inherit; + text-decoration: inherit; +} + +/* +Add the correct font weight in Edge and Safari. +*/ + +:where(.tw-preflight) b, +:where(.tw-preflight) strong { + font-weight: bolder; +} + +/* +1. Use the user's configured `mono` font family by default. +2. Correct the odd `em` font sizing in all browsers. +*/ + +:where(.tw-preflight) code, +:where(.tw-preflight) kbd, +:where(.tw-preflight) samp, +:where(.tw-preflight) pre { + font-family: theme('fontFamily.mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); /* 1 */ + font-size: 1em; /* 2 */ +} + +/* +Add the correct font size in all browsers. +*/ + +:where(.tw-preflight) small { + font-size: 80%; +} + +/* +Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +:where(.tw-preflight) sub, +:where(.tw-preflight) sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +:where(.tw-preflight) sub { + bottom: -0.25em; +} + +:where(.tw-preflight) sup { + top: -0.5em; +} + +/* +1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) +2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) +3. Remove gaps between table borders by default. +*/ + +:where(.tw-preflight) table { + text-indent: 0; /* 1 */ + border-color: inherit; /* 2 */ + border-collapse: collapse; /* 3 */ +} + +/* +1. Change the font styles in all browsers. +2. Remove the margin in Firefox and Safari. +3. Remove default padding in all browsers. +*/ + +:where(.tw-preflight) button, +:where(.tw-preflight) input, +:where(.tw-preflight) optgroup, +:where(.tw-preflight) select, +:where(.tw-preflight) textarea { + font-family: inherit; /* 1 */ + font-feature-settings: inherit; /* 1 */ + font-variation-settings: inherit; /* 1 */ + font-size: 100%; /* 1 */ + font-weight: inherit; /* 1 */ + line-height: inherit; /* 1 */ + color: inherit; /* 1 */ + margin: 0; /* 2 */ + padding: 0; /* 3 */ +} + +/* +Remove the inheritance of text transform in Edge and Firefox. +*/ + +:where(.tw-preflight) button, +:where(.tw-preflight) select { + text-transform: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Remove default button styles. +*/ + +:where(.tw-preflight) button, +:where(.tw-preflight) [type='button'], +:where(.tw-preflight) [type='reset'], +:where(.tw-preflight) [type='submit'] { + -webkit-appearance: button; /* 1 */ + background-color: transparent; /* 2 */ + background-image: none; /* 2 */ +} + +/* +Use the modern Firefox focus style for all focusable elements. +*/ + +:where(.tw-preflight) :-moz-focusring { + outline: auto; +} + +/* +Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:where(.tw-preflight) :-moz-ui-invalid { + box-shadow: none; +} + +/* +Add the correct vertical alignment in Chrome and Firefox. +*/ + +:where(.tw-preflight) progress { + vertical-align: baseline; +} + +/* +Correct the cursor style of increment and decrement buttons in Safari. +*/ + +:where(.tw-preflight) ::-webkit-inner-spin-button, +:where(.tw-preflight) ::-webkit-outer-spin-button { + height: auto; +} + +/* +1. Correct the odd appearance in Chrome and Safari. +2. Correct the outline style in Safari. +*/ + +:where(.tw-preflight) [type='search'] { + -webkit-appearance: textfield; /* 1 */ + outline-offset: -2px; /* 2 */ +} + +/* +Remove the inner padding in Chrome and Safari on macOS. +*/ + +:where(.tw-preflight) ::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Change font properties to `inherit` in Safari. +*/ + +:where(.tw-preflight) ::-webkit-file-upload-button { + -webkit-appearance: button; /* 1 */ + font: inherit; /* 2 */ +} + +/* +Add the correct display in Chrome and Safari. +*/ + +:where(.tw-preflight) summary { + display: list-item; +} + +/* +Removes the default spacing and border for appropriate elements. +*/ + +:where(.tw-preflight) blockquote, +:where(.tw-preflight) dl, +:where(.tw-preflight) dd, +:where(.tw-preflight) h1, +:where(.tw-preflight) h2, +:where(.tw-preflight) h3, +:where(.tw-preflight) h4, +:where(.tw-preflight) h5, +:where(.tw-preflight) h6, +:where(.tw-preflight) hr, +:where(.tw-preflight) figure, +:where(.tw-preflight) p, +:where(.tw-preflight) pre { + margin: 0; +} + +:where(.tw-preflight) fieldset { + margin: 0; + padding: 0; +} + +:where(.tw-preflight) legend { + padding: 0; +} + +:where(.tw-preflight) ol, +:where(.tw-preflight) ul, +:where(.tw-preflight) menu { + list-style: none; + margin: 0; + padding: 0; +} + +/* +Reset default styling for dialogs. +*/ +:where(.tw-preflight) dialog { + padding: 0; +} + +/* +Prevent resizing textareas horizontally by default. +*/ + +:where(.tw-preflight) textarea { + resize: vertical; +} + +/* +1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +2. Set the default placeholder color to the user's configured gray 400 color. +*/ + +:where(.tw-preflight) input::placeholder, +:where(.tw-preflight) textarea::placeholder { + opacity: 1; /* 1 */ + color: theme('colors.gray.400', #9ca3af); /* 2 */ +} + +/* +Set the default cursor for buttons. +*/ + +:where(.tw-preflight) button, +:where(.tw-preflight) [role="button"] { + cursor: pointer; +} + +/* +Make sure disabled buttons don't get the pointer cursor. +*/ +:where(.tw-preflight) :disabled { + cursor: default; +} + +/* +1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) +2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +:where(.tw-preflight) img, +:where(.tw-preflight) svg, +:where(.tw-preflight) video, +:where(.tw-preflight) canvas, +:where(.tw-preflight) audio, +:where(.tw-preflight) iframe, +:where(.tw-preflight) embed, +:where(.tw-preflight) object { + display: block; /* 1 */ + vertical-align: middle; /* 2 */ +} + +/* +Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +:where(.tw-preflight) img, +:where(.tw-preflight) video { + max-width: 100%; + height: auto; +} + +/* Make elements with the HTML hidden attribute stay hidden by default */ +:where(.tw-preflight) [hidden] { + display: none; +} \ No newline at end of file diff --git a/apps/meet/src/components/ActionButton.tsx b/apps/meet/src/components/ActionButton.tsx new file mode 100644 index 0000000..d0df27a --- /dev/null +++ b/apps/meet/src/components/ActionButton.tsx @@ -0,0 +1,32 @@ +import Link from './Link'; + +export interface ActionButtonProps { + icon: React.ComponentType>, + onClick?: () => void; + href?: string; + children: React.ReactNode, +} + +export const ActionButton: React.FC = ({ + onClick, + href, + icon: Icon, + children, +}) => { + return ( + +
+
+ +
+
+
+ {children} +
+ + ); +}; diff --git a/apps/meet/src/components/AppJoinView.tsx b/apps/meet/src/components/AppJoinView.tsx new file mode 100644 index 0000000..d841f7f --- /dev/null +++ b/apps/meet/src/components/AppJoinView.tsx @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react'; +import { PageState } from '../lib/client/pageState'; +import { Page } from './Page'; +import { H1 } from './Text'; +import Button from './Button'; +import Link from './Link'; + +export type AppJoinViewProps = { + page: PageState & { name: 'appJoin' }, +}; + +const AppJoinView: React.FC = ({ page: { meetingNumber, meetingPassword, meetingHostKey } }) => { + const [secondsToOpen, setSecondsToOpen] = useState(meetingHostKey ? 10 : 0); + const joinDirect = () => { + window.open(`zoomus://zoom.us/join?action=join&confno=${meetingNumber}&pwd=${meetingPassword}`, '_self'); + }; + + useEffect(() => { + if (secondsToOpen <= 0) { + joinDirect(); + return; + } + + const timer = setTimeout(() => { + setSecondsToOpen((s) => s - 1); + }, 1000); + // eslint-disable-next-line consistent-return + return () => clearTimeout(timer); + }, [secondsToOpen]); + + const meetingHostKeyMessage = meetingHostKey ? ( + <> +

To manage breakout rooms, use the host key: {meetingHostKey}

+

(Enter this in the 'Participants' window, using the 'Claim Host' button)

+ + ) : null; + + if (secondsToOpen <= 0) { + return ( + +
+

Enjoy your meeting!

+
+ {meetingHostKeyMessage} + +

Button doesn't work? Join via Zoom website

+
+ ); + } + + return ( + +
+

Joining your meeting in {secondsToOpen}...

+
+ {meetingHostKeyMessage} + +
+ ); +}; + +export default AppJoinView; diff --git a/apps/meet/src/components/Button.tsx b/apps/meet/src/components/Button.tsx new file mode 100644 index 0000000..cf9f00b --- /dev/null +++ b/apps/meet/src/components/Button.tsx @@ -0,0 +1,29 @@ +import classNames from 'clsx'; +import Link from './Link'; + +interface Props { + href?: string, + target?: React.HTMLAttributeAnchorTarget, + onClick?: React.EventHandler, + className?: string, + disabled?: boolean, + children?: React.ReactNode, +} + +const Button: React.FC = ({ + children, href, target, onClick, className, disabled, ...other +}) => ( + + {children} + +); + +export default Button; diff --git a/apps/meet/src/components/CustomNameView.tsx b/apps/meet/src/components/CustomNameView.tsx new file mode 100644 index 0000000..ee36b60 --- /dev/null +++ b/apps/meet/src/components/CustomNameView.tsx @@ -0,0 +1,36 @@ +import { useState } from 'react'; +import { PageState } from '../lib/client/pageState'; +import useJoinAs from '../lib/client/useJoinAs'; +import { Page } from './Page'; +import { H1 } from './Text'; +import Button from './Button'; + +export type CustomNameViewProps = { + page: PageState & { name: 'custom' }, + setPage: (page: PageState) => void, +}; + +const CustomNameView: React.FC = ({ page: { cohortClassId }, setPage }) => { + const [name, setName] = useState(''); + const { joinAs, isJoining, joinError } = useJoinAs({ cohortClassId, setPage }); + + return ( + +
+

Hey there! Who are you?

+
+ {isJoining ?

Joining meeting...

: ( + <> +

If you're sure this is the meeting for you, enter your name below

+
{ event.preventDefault(); return joinAs({ name }); }}> + setName(value.target.value)} /> + +
+ + )} + {joinError &&

{joinError}

} +
+ ); +}; + +export default CustomNameView; diff --git a/apps/meet/src/components/Link.tsx b/apps/meet/src/components/Link.tsx new file mode 100644 index 0000000..8ee5ace --- /dev/null +++ b/apps/meet/src/components/Link.tsx @@ -0,0 +1,47 @@ +import NextLink from 'next/link'; +import classNames from 'clsx'; + +interface Props extends Omit, HTMLAnchorElement>, 'ref'> { + href?: string, + target?: React.HTMLAttributeAnchorTarget, + onClick?: React.EventHandler, + className?: string, + disabled?: boolean, + children?: React.ReactNode, +} + +const Link: React.FC = ({ + children, href, target, onClick, className, disabled, ...anchorProps +}) => { + if (disabled || (href === undefined && onClick === undefined)) { + return ( + false} className={classNames('opacity-40 pointer-events-none', className)} {...anchorProps}> + {children} + + ); + } + + const isInternal = href && /^(\.?\/(?!\/))|(\.\.)/.test(href); + + // Use Gatsby Link for internal links, and for others + if (isInternal && href) { + return ( + + {children} + + ); + } + + return ( + { if (e.key === 'Enter' || e.key === ' ') { onClick(e); e.preventDefault(); } } : undefined} tabIndex={0} className={classNames('cursor-pointer', className)} {...anchorProps}> + {children} + + ); +}; + +export default Link; diff --git a/apps/meet/src/components/MeetingView.test.tsx b/apps/meet/src/components/MeetingView.test.tsx new file mode 100644 index 0000000..9d83533 --- /dev/null +++ b/apps/meet/src/components/MeetingView.test.tsx @@ -0,0 +1,7 @@ +import { test, expect } from 'vitest'; +import { version } from '@zoom/meetingsdk/package.json'; +import { ZOOM_VERSION } from './MeetingView'; + +test('zoom package.json version matches source version', async () => { + expect(version).toBe(ZOOM_VERSION); +}); diff --git a/apps/meet/src/components/MeetingView.tsx b/apps/meet/src/components/MeetingView.tsx new file mode 100644 index 0000000..dd85f63 --- /dev/null +++ b/apps/meet/src/components/MeetingView.tsx @@ -0,0 +1,71 @@ +import type { ZoomMtg as ZoomMtgType } from '@zoom/meetingsdk'; +import Script from 'next/script'; +import Head from 'next/head'; +import env from '../lib/client/env'; +import { PageState } from '../lib/client/pageState'; + +type MeetingViewProps = { + page: PageState & { name: 'room' } +}; + +export const ZOOM_VERSION = '3.5.2'; + +declare let ZoomMtg: typeof ZoomMtgType; + +const MeetingView: React.FC = ({ + page: { + jwt, participantName, meetingNumber, meetingPassword, + }, +}) => { + // This setup is based on the guide at: + // https://developers.zoom.us/docs/meeting-sdk/web/client-view/import/#init-the-meeting-sdk + const onZoomLoad = () => { + ZoomMtg.preLoadWasm(); + ZoomMtg.prepareWebSDK(); + + ZoomMtg.init({ + disablePreview: true, + disableInvite: true, + defaultView: 'gallery', + leaveUrl: `/finished${window.location.search}`, + success: () => { + ZoomMtg.join({ + sdkKey: env.NEXT_PUBLIC_ZOOM_CLIENT_ID, + signature: jwt, + userName: participantName, + meetingNumber, + passWord: meetingPassword, + success: () => { + console.log('Joined meeting successfully'); + }, + error: (error: unknown) => { + console.log('Error joining meeting', error); + }, + }); + }, + error: (error: unknown) => { + console.log('Error initializing Zoom client', error); + }, + }); + }; + + return ( +
+ {/* Scripts */} +