This commit is contained in:
pfych 2024-10-12 14:08:09 +11:00
commit 0029086b3f
148 changed files with 19047 additions and 0 deletions

View file

@ -0,0 +1,19 @@
module.exports = {
extends: [
'../../.eslintrc.js',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
],
rules: {
'@typescript-eslint/no-unused-vars': 'warn',
'react/jsx-curly-brace-presence': [
'error',
{ props: 'never', children: 'never' },
],
},
settings: {
react: {
version: 'detect',
},
},
};

View file

@ -0,0 +1,8 @@
module.exports = {
semi: true,
singleQuote: true,
tabWidth: 2,
trailingComma: 'all',
arrowParens: 'always',
printWidth: 80,
};

View file

@ -0,0 +1,20 @@
{
"extends": [
"stylelint-config-sass-guidelines",
"stylelint-config-css-modules"
],
"plugins": ["stylelint-order"],
"configBaseDir": "./",
"customSyntax": "postcss-scss",
"rules": {
"order/properties-alphabetical-order": null,
"max-nesting-depth": null,
"selector-no-qualifying-type": null,
"selector-class-pattern": "(^[a-z]+([A-Z]|-)+\\w+$)+|(^([a-z]|-)+\\w+$)",
"scss/at-mixin-pattern": "(^[a-z]+([A-Z]|-)+\\w+$)+|(^([a-z]|-)+\\w+$)",
"scss/dollar-variable-pattern": "(^[a-z]+([A-Z]|-)+\\w+$)+|(^([a-z]|-)+\\w+$)",
"selector-max-compound-selectors": 8,
"scss/at-extend-no-missing-placeholder": null,
"at-rule-no-unknown": null
}
}

30
packages/admin/index.html Normal file
View file

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Baseline Core</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Baseline Core Admin portal" />
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="manifest.json" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
<script>
var global = global || window;
var Buffer = Buffer || [];
var process = process || { env: { DEBUG: undefined }, version: [] };
</script>
</body>
</html>

View file

@ -0,0 +1,72 @@
{
"name": "@baseline/admin",
"version": "1.0.0",
"type": "commonjs",
"scripts": {
"deploy:staging": "./scripts/deploy.sh staging",
"remove:staging": ". ../../scripts/project-variables.sh && npx serverless remove --stage staging --region $REGION",
"deploy:prod": "./scripts/deploy.sh prod",
"remove:prod": ". ../../scripts/project-variables.sh && npx serverless remove --stage prod --region $REGION",
"aws:profile": "../../scripts/setup-aws-profile.sh",
"start": "npx vite",
"build": "pnpm run generate:env:staging && npx vite build",
"build:deploy": "npx vite build",
"preview": "npx vite preview",
"generate:env:local": "pnpm -w run generate:env:local",
"generate:env:staging": "pnpm -w run generate:env:staging",
"generate:env:prod": "pnpm -w run generate:env:prod",
"lint": "npx stylelint --config '.stylelintrc.json' 'src/**/*.scss' && npx eslint --config '.eslintrc.js' 'src/**/*.{ts,tsx,js}'",
"pretty": "npx prettier --write 'src/**/*.{ts,tsx,js,json,css,scss,md,yml,yaml,html}' && npx prettier --write 'public/**/*.{ts,tsx,js,json,css,scss,md,yml,yaml,html}' && npx prettier --write '*.{ts,tsx,js,json,css,scss,md,yml,yaml,html}'"
},
"dependencies": {
"@aws-amplify/ui-react": "6.1.6",
"@baseline/client-api": "workspace:1.0.0",
"@baseline/types": "workspace:1.0.0",
"@vitejs/plugin-react": "4.2.1",
"aws-amplify": "6.0.20",
"axios": "1.7.7",
"formik": "^2.4.6",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-helmet": "6.1.0",
"react-router-dom": "6.22.3",
"react-select": "5.8.0",
"reactstrap": "9.2.2",
"swr": "^2.2.5",
"typescript": "5.4.2",
"vite": "5.2.6",
"vite-plugin-environment": "1.1.3",
"yup": "^1.4.0"
},
"devDependencies": {
"@types/react": "18.2.67",
"@types/react-dom": "18.2.22",
"eslint-plugin-react": "7.34.0",
"eslint-plugin-react-hooks": "4.6.0",
"postcss": "8.4.35",
"postcss-scss": "4.0.9",
"prettier": "2.4.1",
"sass": "1.43.4",
"serverless": "3.38.0",
"serverless-baseline-invalidate-cloudfront": "0.1.1",
"serverless-s3-sync": "3.1.0",
"stylelint": "16.2.1",
"stylelint-config-css-modules": "4.4.0",
"stylelint-config-sass-guidelines": "11.1.0",
"stylelint-config-standard": "36.0.0",
"stylelint-config-standard-scss": "13.0.0",
"stylelint-order": "6.0.4"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 723 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,7 @@
<svg id="Group_60" data-name="Group 60" xmlns="http://www.w3.org/2000/svg" width="24.748" height="21.723" viewBox="0 0 24.748 21.723">
<g id="ic_photo_camera_24px">
<circle cx="3.2" cy="3.2" r="3.2" transform="translate(5.299 5.216)" fill="#707070"/>
<path d="M7.949,2,6.394,3.7H3.7A1.7,1.7,0,0,0,2,5.4V15.6a1.7,1.7,0,0,0,1.7,1.7H17.3A1.7,1.7,0,0,0,19,15.6V5.4a1.7,1.7,0,0,0-1.7-1.7H14.6L13.048,2ZM10.5,14.748A4.249,4.249,0,1,1,14.748,10.5,4.251,4.251,0,0,1,10.5,14.748Z" transform="translate(-2 -2)" fill="#707070"/>
</g>
<path id="ic_audiotrack_24px" d="M11.192,3v8.03a3.8,3.8,0,0,0-1.3-.242,3.894,3.894,0,1,0,3.85,4.326h.043V5.6h3.461V3Z" transform="translate(7 2.649)" fill="#707070" stroke="#fff" stroke-width="1"/>
</svg>

After

Width:  |  Height:  |  Size: 744 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="19.4" height="20">
<path d="M19.43,12.98A7.793,7.793,0,0,0,19.5,12a7.793,7.793,0,0,0-.07-.98l2.11-1.65a.5.5,0,0,0,.12-.64l-2-3.46a.5.5,0,0,0-.61-.22l-2.49,1a7.306,7.306,0,0,0-1.69-.98l-.38-2.65A.488.488,0,0,0,14,2H10a.488.488,0,0,0-.49.42L9.13,5.07a7.683,7.683,0,0,0-1.69.98l-2.49-1a.488.488,0,0,0-.61.22l-2,3.46a.493.493,0,0,0,.12.64l2.11,1.65A7.931,7.931,0,0,0,4.5,12a7.931,7.931,0,0,0,.07.98L2.46,14.63a.5.5,0,0,0-.12.64l2,3.46a.5.5,0,0,0,.61.22l2.49-1a7.306,7.306,0,0,0,1.69.98l.38,2.65A.488.488,0,0,0,10,22h4a.488.488,0,0,0,.49-.42l.38-2.65a7.683,7.683,0,0,0,1.69-.98l2.49,1a.488.488,0,0,0,.61-.22l2-3.46a.5.5,0,0,0-.12-.64ZM12,15.5A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z" transform="translate(-2.271 -2)" fill="#707070"/>
</svg>

After

Width:  |  Height:  |  Size: 790 B

View file

@ -0,0 +1,3 @@
<svg width="22" height="19" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path fill="#707070" stroke="none" d="M 8.8 19 L 8.8 12.294117 L 13.2 12.294117 L 13.2 19 L 18.700001 19 L 18.700001 10.058824 L 22 10.058824 L 11 0 L 0 10.058824 L 3.3 10.058824 L 3.3 19 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 309 B

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated by Pixelmator Pro 2.4.6 -->
<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path id="iccreate24px-1" fill="#707070" stroke="none" d="M -0 11.086084 L -0 14 L 2.916162 14 L 11.520921 5.393497 L 8.604759 2.483365 Z M 13.772342 3.14162 C 13.918085 2.99629 14 2.798914 14 2.593074 C 14 2.387233 13.918085 2.189857 13.772342 2.044527 L 11.95305 0.227704 C 11.80775 0.081932 11.610414 0 11.404614 0 C 11.198815 0 11.00148 0.081932 10.856179 0.227704 L 9.433084 1.651088 L 12.349246 4.567841 L 13.772342 3.144457 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 656 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="15">
<path id="ic_video_label_24px" d="M17.316,3H2.632A1.628,1.628,0,0,0,1,4.615V15.922a1.628,1.628,0,0,0,1.632,1.615H17.316a1.628,1.628,0,0,0,1.632-1.615V4.615A1.628,1.628,0,0,0,17.316,3Zm0,10.5H2.632V4.615H17.316Z" transform="translate(-1 -3)" fill="#707070"/>
</svg>

After

Width:  |  Height:  |  Size: 331 B

View file

@ -0,0 +1,3 @@
<svg width="17" height="17" viewBox="0 0 17 17" xmlns="http://www.w3.org/2000/svg">
<path fill="#707070" d="M 16.684999 8.042999 L 8.949 0.308001 C 8.627602 -0.013262 8.191428 -0.193201 7.737 -0.191999 L 1.719 -0.191999 C 0.770762 -0.189255 0.002746 0.578762 0 1.527 L 0 7.545 C -0.000972 8.003402 0.181611 8.443114 0.507 8.766 L 8.244 16.503 C 8.565398 16.824263 9.001572 17.004202 9.456 17.003 C 9.912048 17.005405 10.349501 16.822411 10.668 16.496 L 16.684999 10.479 C 17.01141 10.160501 17.194405 9.723047 17.191999 9.267 C 17.191452 8.80802 17.009161 8.367934 16.684999 8.042999 Z M 3.009 4.108 C 2.297627 4.106895 1.721633 3.529708 1.722002 2.818335 C 1.72237 2.10696 2.298961 1.53037 3.010334 1.530002 C 3.721708 1.529634 4.298895 2.105627 4.3 2.816999 C 4.300799 3.159638 4.16504 3.488474 3.922757 3.730757 C 3.680474 3.97304 3.351639 4.108799 3.009 4.108 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 880 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path id="ic_account_circle_24px" d="M12,2A10,10,0,1,0,22,12,10,10,0,0,0,12,2Zm0,3A3,3,0,1,1,9,8,3,3,0,0,1,12,5Zm0,14.2a7.2,7.2,0,0,1-6-3.22c.03-1.99,4-3.08,6-3.08s5.97,1.09,6,3.08A7.2,7.2,0,0,1,12,19.2Z" transform="translate(-2 -2)" fill="#707070"/>
</svg>

After

Width:  |  Height:  |  Size: 344 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,25 @@
{
"short_name": "Baseline Core",
"name": "Baseline Core",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="svg839" version="1.1" height="1px" width="1px"><g><rect y="0" x="0" height="1px" width="1px" style="fill:#bababa;"/></g></svg>

After

Width:  |  Height:  |  Size: 171 B

View file

@ -0,0 +1,2 @@
User-agent: *
Disallow:

View file

@ -0,0 +1,18 @@
#!/usr/bin/env bash
CURRENT_DIR="$(pwd -P)"
PARENT_PATH="$(
cd "$(dirname "${BASH_SOURCE[0]}")" || exit
pwd -P
)/.."
cd "$PARENT_PATH" || exit
STAGE=$1
pnpm run generate:env:"$STAGE"
. ../../scripts/project-variables.sh
. ../../scripts/get-stack-outputs.sh "$STAGE" >/dev/null
pnpm run build:deploy
npx serverless deploy --verbose --stage "$STAGE" --region "$REGION"
cd "$CURRENT_DIR" || exit

View file

@ -0,0 +1,177 @@
service: ${env:APP_NAME}-admin
frameworkVersion: '>=2.0.0 <4.0.0'
plugins:
- serverless-s3-sync
- serverless-baseline-invalidate-cloudfront
custom:
s3Sync:
- bucketNameKey: S3Bucket
localDir: .dist/
cloudfrontInvalidate:
- distributionIdKey: 'CDNDistributionId'
items:
- '/*'
# domain:
# local: "local-admin.baselinejs.com"
# staging: "staging-admin.baselinejs.com"
# prod: "admin.baselinejs.com"
provider:
name: aws
runtime: nodejs20.x
profile: ${env:AWS_PROFILE}
stage: ${opt:stage}
deploymentMethod: direct
deploymentPrefix: ${self:service}-${sls:stage}
stackTags:
AppName: ${env:APP_NAME}
Stage: ${opt:stage}
Region: ${opt:region}
Product: Baseline
# The "Resources" your "Functions" use. Raw AWS CloudFormation goes in here.
resources:
Description: ${env:APP_NAME} ${opt:stage}
Resources:
## Specifying the S3 Bucket
WebsiteS3Bucket:
Type: AWS::S3::Bucket
Properties:
OwnershipControls:
Rules:
- ObjectOwnership: BucketOwnerPreferred
PublicAccessBlockConfiguration:
BlockPublicAcls: false
BlockPublicPolicy: false
IgnorePublicAcls: false
RestrictPublicBuckets: false
## Specifying the policies to make sure all files inside the Bucket are available to CloudFront
WebsiteS3BucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket:
Ref: WebsiteS3Bucket
PolicyDocument:
Statement:
- Sid: PublicReadGetObject
Effect: Allow
Principal:
Service: cloudfront.amazonaws.com
Action:
- s3:GetObject
Resource: !Join
- ''
- - 'arn:aws:s3:::'
- !Ref WebsiteS3Bucket
- /*
Condition:
StringEquals:
AWS:SourceArn:
!Join [
'',
[
'arn:aws:cloudfront::',
!Ref AWS::AccountId,
':distribution/',
!Ref WebsiteCloudFrontDistribution,
],
]
CloudfrontResponsePolicy:
Type: AWS::CloudFront::ResponseHeadersPolicy
Properties:
ResponseHeadersPolicyConfig:
Name: ${self:service}-${sls:stage}-no-cache-headers
CustomHeadersConfig:
Items:
- Header: 'Cache-Control'
Override: true
Value: 'no-cache'
# OAC Role for the Cloudfront distribution to block direct S3 Access
WebsiteCloudFrontDistributionOriginAccessControl:
Type: AWS::CloudFront::OriginAccessControl
Properties:
OriginAccessControlConfig:
Name: ${self:service}-${sls:stage}-cloudfront-oac
OriginAccessControlOriginType: s3
SigningBehavior: always
SigningProtocol: sigv4
## Specifying the CloudFront Distribution to serve your Web Application
WebsiteCloudFrontDistribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
HttpVersion: http2
Origins:
- DomainName: !GetAtt WebsiteS3Bucket.RegionalDomainName
## An identifier for the origin which must be unique within the distribution
Id: !GetAtt WebsiteS3Bucket.RegionalDomainName
OriginAccessControlId: !Ref WebsiteCloudFrontDistributionOriginAccessControl
S3OriginConfig:
OriginAccessIdentity: ''
Enabled: true
## [Custom Domain] Add the domain alias
# Aliases:
# - ${self:custom.domain.${opt:stage}}
DefaultRootObject: 'index.html'
## Since the Single Page App is taking care of the routing we need to make sure ever path is served with index.html
## The only exception are files that actually exist e.h. app.js, reset.css
CustomErrorResponses:
- ErrorCode: 403
ResponseCode: 200
ResponsePagePath: '/index.html'
DefaultCacheBehavior:
AllowedMethods:
- GET
- HEAD
CachedMethods:
- HEAD
- GET
Compress: true
DefaultTTL: 1800
MinTTL: 0
## The origin id defined above
TargetOriginId: !GetAtt WebsiteS3Bucket.RegionalDomainName
## Defining if and how the QueryString and Cookies are forwarded to the origin which in this case is S3
ForwardedValues:
QueryString: false
Cookies:
Forward: none
## The protocol that users can use to access the files in the origin. To allow HTTP use `allow-all`
ViewerProtocolPolicy: redirect-to-https
CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6
ResponseHeadersPolicyId: !Ref CloudfrontResponsePolicy
## The certificate to use when viewers use HTTPS to request objects.
ViewerCertificate:
CloudFrontDefaultCertificate: true
## [Custom Domain] Stop using the cloudfront default certificate, uncomment below and add ACM Certificate ARN
# MinimumProtocolVersion: TLSv1.2_2021
# SslSupportMethod: sni-only
# AcmCertificateArn: arn:aws:acm:us-east-1:xxxxxxxxxxxx:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # ARN of the AWS certificate
## Uncomment the following section in case you want to enable logging for CloudFront requests
# Logging:
# IncludeCookies: 'false'
# Bucket: mylogs.s3.amazonaws.com
# Prefix: myprefix
## In order to print out the hosted domain via `serverless info` we need to define the DomainName output for CloudFormation
Outputs:
AdminCloudFrontUrl:
Description: The Admin URL
Value:
'Fn::GetAtt': [WebsiteCloudFrontDistribution, DomainName]
AdminCloudFrontDistributionId:
Description: CloudFront Distribution Id
Value:
Ref: WebsiteCloudFrontDistribution
CDNDistributionId:
Description: CloudFront Distribution Id for serverless-cloudfront-invalidate
Value:
Ref: WebsiteCloudFrontDistribution
S3Bucket:
Description: S3 Bucket
Value:
Ref: WebsiteS3Bucket

132
packages/admin/src/App.tsx Normal file
View file

@ -0,0 +1,132 @@
import React, { useEffect } from 'react';
import { Amplify } from 'aws-amplify';
import { fetchAuthSession } from 'aws-amplify/auth';
import { Hub } from 'aws-amplify/utils';
import { checkAdmin } from '@baseline/client-api/admin';
import {
Outlet,
RouterProvider,
createBrowserRouter,
redirect,
} from 'react-router-dom';
import '@aws-amplify/ui-react/styles.css';
import Dashboard from './baseblocks/dashboard/pages/Dashboard';
import User, { userLoader } from './baseblocks/user/pages/User';
import Admins, { adminListLoader } from './baseblocks/admin/pages/Admins';
import {
createRequestHandler,
getRequestHandler,
} from '@baseline/client-api/request-handler';
import { AxiosRequestConfig } from 'axios';
import Home from './baseblocks/home/pages/Home';
import Login from './baseblocks/login/pages/Login';
import NotAdmin from './baseblocks/not-admin/pages/NotAdmin';
import Layout from './components/layout/Layout';
import Loader from './components/page-content/loader/Loader';
import Charts from './baseblocks/chart/pages/charts';
import Chart from './baseblocks/chart/pages/Chart';
Amplify.configure({
Auth: {
Cognito: {
signUpVerificationMethod: 'code',
identityPoolId: process.env.REACT_APP_COGNITO_IDENTITY_POOL_ID || '',
userPoolId: process.env.REACT_APP_COGNITO_USER_POOL_ID || '',
userPoolClientId:
process.env.REACT_APP_COGNITO_USER_POOL_WEB_CLIENT_ID || '',
},
},
});
export default function App() {
useEffect(() => {
return Hub.listen('auth', (data) => {
console.debug('auth event', data.payload.event);
switch (data.payload.event) {
case 'signedIn':
router.navigate('/dashboard').catch((e) => console.error(e));
break;
case 'signedOut':
router.navigate('/').catch((e) => console.error(e));
break;
case 'signInWithRedirect_failure':
break;
case 'tokenRefresh':
break;
default:
console.debug(`Unhandled event: ${data.payload.event}`);
}
});
}, []);
return (
<RouterProvider
router={router}
fallbackElement={<Loader hasStartedLoading={true} />}
/>
);
}
async function protectedLoader() {
console.debug('protected loader');
if (!getRequestHandler()) {
console.debug('creating request handler');
createRequestHandler(
async (config: AxiosRequestConfig): Promise<AxiosRequestConfig> => {
const authSession = await fetchAuthSession();
if (!config.headers) config.headers = {};
config.headers.Authorization = `Bearer ${authSession?.tokens?.idToken}`;
return config;
},
);
}
const authSession = await fetchAuthSession();
if (!authSession?.tokens?.idToken) {
return redirect('/login');
}
const isAdmin = await checkAdmin(getRequestHandler());
if (!isAdmin) {
return redirect('/not-admin');
}
return null;
}
async function loginLoader() {
console.debug('login loader');
const authSession = await fetchAuthSession();
if (authSession?.tokens?.idToken) {
console.debug('redirecting to dashboard');
return redirect('/dashboard');
}
return null;
}
const router = createBrowserRouter([
{
id: 'public',
path: '/',
Component: Outlet,
children: [
{ path: '/', Component: Home, index: true },
{ path: '/not-admin', Component: NotAdmin },
{ path: '/login', Component: Login, loader: loginLoader },
],
},
{
id: 'protected',
path: '/',
Component: Layout,
loader: protectedLoader,
children: [
{ path: '/dashboard', Component: Dashboard },
{
path: '/admins',
Component: Admins,
loader: adminListLoader,
},
{ path: '/settings', Component: User, loader: userLoader },
{ path: '/chart', Component: Charts },
{ path: '/chart/:chartId', Component: Chart },
],
},
]);

View file

@ -0,0 +1,12 @@
@use '../../../../styles/global';
.addUser,
.addUserModal {
.addUserButton {
@include global.smallFont();
padding: 6px 12px;
background: unset;
border: 1px solid #bababa;
}
}

View file

@ -0,0 +1,74 @@
import { createAdmin } from '@baseline/client-api/admin';
import React, { useState } from 'react';
import {
FormGroup,
Input,
Label,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
} from 'reactstrap';
import { getRequestHandler } from '@baseline/client-api/request-handler';
import styles from './AddAdmin.module.scss';
import { Admin } from '@baseline/types/admin';
interface Props {
setAllAdmins: React.Dispatch<React.SetStateAction<Admin[]>>;
}
const AddAdmin = (props: Props) => {
const { setAllAdmins } = props;
const [newEmail, setNewEmail] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const toggle = () => {
setNewEmail('');
setIsModalOpen((open) => !open);
};
const addUser = async (): Promise<void> => {
const newAdmin = await createAdmin(getRequestHandler(), {
userEmail: newEmail,
});
setAllAdmins((admins) => [...admins, newAdmin]);
toggle();
};
return (
<div className={styles.addUser}>
<button className={styles.addUserButton} onClick={toggle}>
Invite
</button>
<Modal
className={styles.addUserModal}
isOpen={isModalOpen}
toggle={toggle}
centered
>
<ModalHeader toggle={toggle}>Add Admin</ModalHeader>
<ModalBody>
<FormGroup>
<Label>Email</Label>
<Input
onChange={(e) => setNewEmail(e.target.value)}
value={newEmail}
/>
</FormGroup>
</ModalBody>
<ModalFooter>
<button
disabled={!newEmail}
className={styles.addUserButton}
onClick={() => {
void addUser();
}}
>
Add
</button>
</ModalFooter>
</Modal>
</div>
);
};
export default AddAdmin;

View file

@ -0,0 +1,69 @@
@use '../../../../styles/global';
.userList {
.admin,
.header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-width: 0;
padding: 18px 48px;
overflow: hidden;
background: #fff;
border: 1px solid #bababa;
@media screen and (max-width: global.$lg) {
padding: 12px;
}
&:not(:last-of-type) {
border-bottom: unset;
}
.userCount {
@include global.smallFont();
margin-right: 8px;
}
.info {
flex: 1 1 auto;
min-width: 0;
.details {
margin-bottom: 8px;
.name,
.data {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.name {
@include global.largeFont();
margin-bottom: 8px;
font-weight: bold;
}
.data {
@include global.mediumFont();
color: #707070;
}
}
.pill {
@include global.smallFont();
width: min-content;
padding: 6px 18px;
border: 1px solid #bababa;
border-radius: 10px;
}
}
.buttons {
flex: 0 0 auto;
min-width: min-content;
}
}
}

View file

@ -0,0 +1,56 @@
import React, { useState } from 'react';
import { deleteAdmin } from '@baseline/client-api/admin';
import ConfirmDelete from '../../../../components/confirm-delete/ConfirmDelete';
import AddUser from '../add-admin/AddAdmin';
import styles from './AdminList.module.scss';
import { getRequestHandler } from '@baseline/client-api/request-handler';
import { Admin } from '@baseline/types/admin';
interface Props {
admins: Admin[];
}
const AdminList = (props: Props): JSX.Element => {
const [allAdmins, setAllAdmins] = useState<Admin[]>(props?.admins || []);
const handleDelete = async (adminSub: string): Promise<void> => {
await deleteAdmin(getRequestHandler(), { adminId: adminSub });
setAllAdmins((admins) =>
admins.filter((admin) => admin.userSub !== adminSub),
);
};
return (
<div className={styles.userList}>
<div className={styles.list}>
<div className={styles.header}>
<div className={styles.userCount}>
There are {allAdmins.length} people in your team
</div>
<AddUser setAllAdmins={setAllAdmins} />
</div>
{allAdmins.map((admin) => (
<div key={admin.userSub} className={styles.admin}>
<div className={styles.info}>
<div className={styles.details}>
<div className={styles.name}>{admin.userEmail}</div>
<div className={styles.data}>{admin.userSub}</div>
</div>
<div className={styles.pill}>Admin</div>
</div>
<div className={styles.buttons}>
<ConfirmDelete
itemName={admin.userEmail}
deleteFunction={async () => {
await handleDelete(admin.userSub);
}}
/>
</div>
</div>
))}
</div>
</div>
);
};
export default AdminList;

View file

@ -0,0 +1,26 @@
import React from 'react';
import { Admin } from '@baseline/types/admin';
import AdminList from '../components/admin-list/AdminList';
import { useLoaderData } from 'react-router-dom';
import { getAllAdmins } from '@baseline/client-api/admin';
import { getRequestHandler } from '@baseline/client-api/request-handler';
import PageContent from '../../../components/page-content/PageContent';
export async function adminListLoader() {
const admins = await getAllAdmins(getRequestHandler());
return {
admins: admins,
};
}
const Admins = (): JSX.Element => {
const { admins } = useLoaderData() as { admins: Admin[] };
return (
<PageContent>
<AdminList admins={admins} />
</PageContent>
);
};
export default Admins;

View file

@ -0,0 +1,7 @@
.chartList {
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
}

View file

@ -0,0 +1,35 @@
import React from 'react';
import styles from './ChartDetail.module.scss';
import { useChart } from '../../../../swr/chart';
import Loader from '../../../../components/page-content/loader/Loader';
import EditChartModal from '../edit-chart-modal/EditChartModal';
interface Props {
chartId: string;
}
const ChartDetail = (props: Props): JSX.Element => {
const { chartId } = props;
const { chart, isLoading, error } = useChart(chartId);
if (isLoading) {
return <Loader hasStartedLoading={true} isLoading={true} />;
}
if (error) {
return <div>{JSON.stringify(error)}</div>;
}
return (
<div className={styles.chartList}>
<div className={styles.header}>
<h1>Charts</h1>
<EditChartModal chart={chart} />
</div>
<hr />
<pre>{JSON.stringify(chart, null, 2)}</pre>
</div>
);
};
export default ChartDetail;

View file

@ -0,0 +1,7 @@
.chartList {
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
}

View file

@ -0,0 +1,32 @@
import React from 'react';
import styles from './ChartList.module.scss';
import CreateChartModal from '../create-chart-modal/CreateChartModal';
import { useCharts } from '../../../../swr/chart';
import Loader from '../../../../components/page-content/loader/Loader';
const ChartList = (): JSX.Element => {
const { charts, isLoading } = useCharts();
if (isLoading) {
return <Loader hasStartedLoading={true} isLoading={true} />;
}
return (
<div className={styles.chartList}>
<div className={styles.header}>
<h1>Charts</h1>
<CreateChartModal />
</div>
<hr />
<ul>
{charts.map((chart) => (
<li key={chart.chartId}>
<a href={`/chart/${chart.chartId}`}>{chart.name}</a>
</li>
))}
</ul>
</div>
);
};
export default ChartList;

View file

@ -0,0 +1,183 @@
import React, { useMemo, useState } from 'react';
import { Chart, chartSchema } from '@baseline/types/chart';
import { Formik } from 'formik';
import * as Yup from 'yup';
import {
Button,
FormFeedback,
FormGroup,
Input,
Label,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
} from 'reactstrap';
import { createChart } from '@baseline/client-api/chart';
import { onChartCreated } from '../../../../swr/chart';
const CreateChartModal = (): JSX.Element => {
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen(!isOpen);
const initialValues: Chart = useMemo(
() => ({
chartId: undefined,
parentChart: 'NO_PARENT',
name: '',
md5: '',
sha256: '',
resourceUri: '',
comment: '',
}),
[],
);
return (
<Formik
initialValues={initialValues}
enableReinitialize
validationSchema={chartSchema}
onSubmit={async (values, { setSubmitting, resetForm }) => {
setSubmitting(true);
const newChart = await createChart(values);
await onChartCreated(newChart);
resetForm();
setIsOpen(false);
}}
>
{({
values,
handleSubmit,
isValid,
isSubmitting,
resetForm,
errors,
setFieldValue,
handleBlur,
}) => (
<>
<Button
onClick={() => {
resetForm();
setIsOpen(true);
}}
>
Create Chart
</Button>
<Modal
centered
isOpen={isOpen}
toggle={() => {
toggle();
resetForm();
}}
>
<ModalHeader toggle={toggle}>Create Chart</ModalHeader>
<ModalBody>
<FormGroup>
<Label htmlFor="name">Name</Label>
<Input
name="name"
id="name"
value={values.name}
onChange={(e) => void setFieldValue('name', e.target.value)}
onBlur={handleBlur}
className={errors.name ? 'is-invalid' : ''}
/>
<FormFeedback>{errors.name}</FormFeedback>
</FormGroup>
<FormGroup>
<Label htmlFor="parentChart">Parent Chart</Label>
<Input
name="parentChart"
id="parentChart"
value={values.parentChart}
onChange={(e) =>
void setFieldValue('parentChart', e.target.value)
}
onBlur={handleBlur}
className={errors.parentChart ? 'is-invalid' : ''}
disabled
placeholder="This will be a dropdown at some point"
/>
<FormFeedback>{errors.parentChart}</FormFeedback>
</FormGroup>
<FormGroup>
<Label htmlFor="md5">MD5</Label>
<Input
name="md5"
id="md5"
value={values.md5}
onChange={(e) => void setFieldValue('md5', e.target.value)}
onBlur={handleBlur}
className={errors.md5 ? 'is-invalid' : ''}
/>
<FormFeedback>{errors.md5}</FormFeedback>
</FormGroup>
<FormGroup>
<Label htmlFor="sha256">SHA256</Label>
<Input
name="sha256"
id="sha256"
value={values.sha256}
onChange={(e) => void setFieldValue('sha256', e.target.value)}
onBlur={handleBlur}
className={errors.sha256 ? 'is-invalid' : ''}
/>
<FormFeedback>{errors.sha256}</FormFeedback>
</FormGroup>
<FormGroup>
<Label htmlFor="resourceUri">Resource URI</Label>
<Input
name="resourceUri"
id="resourceUri"
value={values.resourceUri}
onChange={(e) =>
void setFieldValue('resourceUri', e.target.value)
}
onBlur={handleBlur}
className={errors.resourceUri ? 'is-invalid' : ''}
/>
<FormFeedback>{errors.resourceUri}</FormFeedback>
</FormGroup>
<FormGroup>
<Label htmlFor="comment">Comment</Label>
<textarea
name="comment"
id="comment"
value={values.comment}
onChange={(e) =>
void setFieldValue('comment', e.target.value)
}
onBlur={handleBlur}
className={`${
errors.comment ? 'is-invalid' : ''
} form-control`}
/>
<FormFeedback>{errors.comment}</FormFeedback>
</FormGroup>
</ModalBody>
<ModalFooter>
<Button color="secondary" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button
onClick={() => {
void handleSubmit();
}}
disabled={!isValid || isSubmitting}
color="primary"
>
Create
</Button>
</ModalFooter>
</Modal>
</>
)}
</Formik>
);
};
export default CreateChartModal;

View file

@ -0,0 +1,175 @@
import React, { useState } from 'react';
import { Chart, chartSchema } from '@baseline/types/chart';
import { Formik } from 'formik';
import {
Button,
FormFeedback,
FormGroup,
Input,
Label,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
} from 'reactstrap';
import { updateChart } from '@baseline/client-api/chart';
import { onChartUpdated } from '../../../../swr/chart';
interface Props {
chart: Chart;
}
const EditChartModal = (props: Props): JSX.Element => {
const { chart } = props;
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen(!isOpen);
return (
<Formik
initialValues={chart}
enableReinitialize
validationSchema={chartSchema}
onSubmit={async (values, { setSubmitting, resetForm }) => {
setSubmitting(true);
const newChart = await updateChart(values);
await onChartUpdated(newChart);
resetForm();
setIsOpen(false);
setSubmitting(false);
}}
>
{({
values,
handleSubmit,
isValid,
isSubmitting,
resetForm,
errors,
setFieldValue,
handleBlur,
}) => (
<>
<Button
onClick={() => {
resetForm();
setIsOpen(true);
}}
>
Edit Chart
</Button>
<Modal
centered
isOpen={isOpen}
toggle={() => {
toggle();
resetForm();
}}
>
<ModalHeader toggle={toggle}>Create Chart</ModalHeader>
<ModalBody>
<FormGroup>
<Label htmlFor="name">Name</Label>
<Input
name="name"
id="name"
value={values.name}
onChange={(e) => void setFieldValue('name', e.target.value)}
onBlur={handleBlur}
className={errors.name ? 'is-invalid' : ''}
/>
<FormFeedback>{errors.name}</FormFeedback>
</FormGroup>
<FormGroup>
<Label htmlFor="parentChart">Parent Chart</Label>
<Input
name="parentChart"
id="parentChart"
value={values.parentChart}
onChange={(e) =>
void setFieldValue('parentChart', e.target.value)
}
onBlur={handleBlur}
className={errors.parentChart ? 'is-invalid' : ''}
disabled
placeholder={'This will be a dropdown at some point'}
/>
<FormFeedback>{errors.parentChart}</FormFeedback>
</FormGroup>
<FormGroup>
<Label htmlFor="md5">MD5</Label>
<Input
name="md5"
id="md5"
value={values.md5}
onChange={(e) => void setFieldValue('md5', e.target.value)}
onBlur={handleBlur}
className={errors.md5 ? 'is-invalid' : ''}
/>
<FormFeedback>{errors.md5}</FormFeedback>
</FormGroup>
<FormGroup>
<Label htmlFor="sha256">SHA256</Label>
<Input
name="sha256"
id="sha256"
value={values.sha256}
onChange={(e) => void setFieldValue('sha256', e.target.value)}
onBlur={handleBlur}
className={errors.sha256 ? 'is-invalid' : ''}
/>
<FormFeedback>{errors.sha256}</FormFeedback>
</FormGroup>
<FormGroup>
<Label htmlFor="resourceUri">Resource URI</Label>
<Input
name="resourceUri"
id="resourceUri"
value={values.resourceUri}
onChange={(e) =>
void setFieldValue('resourceUri', e.target.value)
}
onBlur={handleBlur}
className={errors.resourceUri ? 'is-invalid' : ''}
/>
<FormFeedback>{errors.resourceUri}</FormFeedback>
</FormGroup>
<FormGroup>
<Label htmlFor="comment">Comment</Label>
<textarea
name="comment"
id="comment"
value={values.comment}
onChange={(e) =>
void setFieldValue('comment', e.target.value)
}
onBlur={handleBlur}
className={`${
errors.comment ? 'is-invalid' : ''
} form-control`}
/>
<FormFeedback>{errors.comment}</FormFeedback>
</FormGroup>
</ModalBody>
<ModalFooter>
<Button color="secondary" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button
onClick={() => {
void handleSubmit();
}}
disabled={!isValid || isSubmitting}
color="primary"
>
Update
</Button>
</ModalFooter>
</Modal>
</>
)}
</Formik>
);
};
export default EditChartModal;

View file

@ -0,0 +1,16 @@
import React from 'react';
import PageContent from '../../../components/page-content/PageContent';
import { useParams } from 'react-router-dom';
import ChartDetail from '../components/chart-detail/ChartDetail';
const Chart = (): JSX.Element => {
const { chartId } = useParams();
return (
<PageContent>
<ChartDetail chartId={chartId} />
</PageContent>
);
};
export default Chart;

View file

@ -0,0 +1,13 @@
import React from 'react';
import PageContent from '../../../components/page-content/PageContent';
import ChartList from '../components/chart-list/ChartList';
const charts = (): JSX.Element => {
return (
<PageContent>
<ChartList />
</PageContent>
);
};
export default charts;

View file

@ -0,0 +1,55 @@
@use '../../../../styles/global';
.dashboard {
h1 {
margin-bottom: 32px;
}
.grid {
display: grid;
grid-gap: 16px;
grid-template-columns: 1fr 1fr;
@media screen and (max-width: global.$xl) {
grid-template-columns: 1fr;
}
.links {
a {
@include global.mediumFont();
display: flex;
align-items: center;
margin-bottom: 16px;
padding: 32px;
color: #000;
text-decoration: none;
background: #fff;
border: 1px solid #bababa;
&::before {
@include global.mediumFont();
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin-right: 16px;
color: #fff;
font-weight: bold;
background: #000;
border-radius: 10px;
content: '+';
}
}
}
.preview {
margin-bottom: 16px;
padding: 32px;
background: #fff;
border: 1px solid #bababa;
}
}
}

View file

@ -0,0 +1,17 @@
import React from 'react';
import styles from './DashboardContent.module.scss';
const DashboardContent = (): JSX.Element => {
return (
<div className={styles.dashboard}>
<h1>Dashboard</h1>
<div className={styles.grid}>
<div className={styles.preview}>
<h2>My Site Preview</h2>
</div>
</div>
</div>
);
};
export default DashboardContent;

View file

@ -0,0 +1,11 @@
import React from 'react';
import PageContent from '../../../components/page-content/PageContent';
import DashboardContent from '../components/dashboard-content/DashboardContent';
const Dashboard = (): JSX.Element => (
<PageContent>
<DashboardContent />
</PageContent>
);
export default Dashboard;

View file

@ -0,0 +1,22 @@
@use '../../../styles/global';
.home {
display: flex;
min-height: 100vh;
overflow: hidden;
flex-direction: row;
flex: 1 1 auto;
> div:last-of-type {
flex: 1 1 auto;
min-width: min(100vw, 390px);
}
.content {
display: flex;
flex-direction: column;
flex: 1 1 auto;
justify-content: center;
align-items: center;
}
}

View file

@ -0,0 +1,16 @@
import React from 'react';
import { Link } from 'react-router-dom';
import styles from './Home.module.scss';
function Home() {
return (
<div className={styles.home}>
<div className={styles.content}>
<h1>Home</h1>
<p>Welcome to the home page</p>
<Link to="/dashboard">Dashboard</Link>
</div>
</div>
);
}
export default Home;

View file

@ -0,0 +1,22 @@
@use '../../../styles/global';
.login {
display: flex;
min-height: 100vh;
overflow: hidden;
flex-direction: row;
flex: 1 1 auto;
> div:last-of-type {
flex: 1 1 auto;
min-width: min(100vw, 390px);
}
.content {
display: flex;
flex-direction: column;
flex: 1 1 auto;
justify-content: center;
align-items: center;
}
}

View file

@ -0,0 +1,14 @@
import React from 'react';
import { Authenticator } from '@aws-amplify/ui-react';
import styles from './Login.module.scss';
function Login() {
return (
<div className={styles.login}>
<div className={styles.content}>
<Authenticator />
</div>
</div>
);
}
export default Login;

View file

@ -0,0 +1,26 @@
@use '../../../styles/global';
.notAdmin {
display: flex;
min-height: 100vh;
overflow: hidden;
flex-direction: row;
flex: 1 1 auto;
> div:last-of-type {
flex: 1 1 auto;
min-width: min(100vw, 390px);
}
.content {
display: flex;
flex-direction: column;
flex: 1 1 auto;
justify-content: center;
align-items: center;
button {
@include global.genericButton();
}
}
}

View file

@ -0,0 +1,31 @@
import React from 'react';
import styles from './NotAdmin.module.scss';
import { signOut } from 'aws-amplify/auth';
import { redirect } from 'react-router-dom';
async function signOutButton() {
await signOut();
redirect('/');
}
function NotAdmin() {
return (
<div className={styles.notAdmin}>
<div className={styles.content}>
<div>
<h1>Please contact your system administrator</h1>
<p>You do not have permission to view this content</p>
<button
onClick={() => {
void signOutButton();
}}
>
Sign out
</button>
</div>
</div>
</div>
);
}
export default NotAdmin;

View file

@ -0,0 +1,52 @@
@use '../../../../styles/global';
.userSettings {
h1 {
@include global.largeFont();
margin-bottom: 32px;
font-weight: 700;
}
.settings {
@include global.smallFont();
align-items: center;
width: 100%;
padding: 18px 48px;
background: #fff;
border: 1px solid #bababa;
@media screen and (max-width: global.$lg) {
padding: 12px;
}
label {
font-weight: 600;
}
input {
padding: 16px;
border: 1px solid #bababa;
border-radius: unset;
}
button {
@include global.smallFont();
padding: 12px 24px;
background: #fff;
border: 1px solid #bababa;
}
.email {
display: flex;
button {
border-left: unset;
}
}
.signOut {
@include global.smallFont();
}
}
}

View file

@ -0,0 +1,128 @@
import {
signOut,
updateUserAttributes,
confirmUserAttribute,
fetchUserAttributes,
} from 'aws-amplify/auth';
import React, { useState } from 'react';
import { FormFeedback, FormGroup, Input, Label } from 'reactstrap';
import styles from './UserSettings.module.scss';
interface Props {
user: { email: string; email_verified: boolean };
}
const UserSettings = (props: Props): JSX.Element => {
const [email, setEmail] = useState<string>(props?.user?.email);
const [isChangingEmail, setIsChangingEmail] = useState(false);
const [isEmailVerified, setIsEmailVerified] = useState(
props?.user?.email_verified,
);
const [isCodeInvalid, setIsCodeInvalid] = useState<undefined | boolean>();
const [changingEmailCode, setChangingEmailCode] = useState('');
const handleEmailChange = async () => {
const attributes = await fetchUserAttributes();
setIsChangingEmail(false);
if (attributes.email !== email) {
setIsEmailVerified(false);
await updateUserAttributes({
userAttributes: {
email: email,
},
});
}
};
const finalizeEmailChange = async () => {
try {
await confirmUserAttribute({
userAttributeKey: 'email',
confirmationCode: changingEmailCode,
});
setIsEmailVerified(true);
setChangingEmailCode('');
} catch {
console.log('Invalid code');
setIsCodeInvalid(true);
}
};
return (
<div className={styles.userSettings}>
<h1>Account settings</h1>
<div className={styles.settings}>
<FormGroup>
<Label for="email">Email</Label>
<div className={styles.email}>
<Input
name="email"
id="email"
type="email"
placeholder="Email"
value={email}
disabled={!isChangingEmail}
onChange={(e) => setEmail(e.target.value)}
/>
<button
disabled={!isEmailVerified}
onClick={() => {
isChangingEmail
? void handleEmailChange()
: setIsChangingEmail(true);
}}
>
{isChangingEmail ? 'Update' : 'Edit'}
</button>
</div>
</FormGroup>
{!isEmailVerified ? (
<FormGroup>
<Label for="code">Check your email for a code</Label>
<div className={styles.email}>
<Input
name="code"
id="code"
type="text"
placeholder="Code"
invalid={isCodeInvalid}
value={changingEmailCode}
onChange={(e) => setChangingEmailCode(e.target.value)}
/>
<button
onClick={() => {
void finalizeEmailChange();
}}
>
Submit
</button>
<button
onClick={() => {
setIsEmailVerified(true);
setIsChangingEmail(true);
}}
>
Cancel
</button>
</div>
<FormFeedback className={isCodeInvalid ? 'd-block' : ''}>
Code is invalid
</FormFeedback>
</FormGroup>
) : (
<></>
)}
<button
className={styles.signOut}
onClick={() => {
void signOut();
}}
>
Sign out
</button>
</div>
</div>
);
};
export default UserSettings;

View file

@ -0,0 +1,34 @@
import React from 'react';
import PageContent from '../../../components/page-content/PageContent';
import UserSettings from '../components/user-settings/UserSettings';
import { fetchUserAttributes } from 'aws-amplify/auth';
import { useLoaderData } from 'react-router-dom';
export async function userLoader() {
const { email, email_verified } = await fetchUserAttributes();
return {
user: {
email,
email_verified,
},
};
}
const User = (): JSX.Element => {
const { user } = useLoaderData() as {
user: { email: string; email_verified: boolean };
};
return (
<PageContent>
<UserSettings
user={{
email: user.email,
email_verified: user.email_verified,
}}
/>
</PageContent>
);
};
export default User;

View file

@ -0,0 +1,19 @@
.confirmDelete {
display: flex;
align-items: center;
}
.deleteModal,
.confirmDelete {
.deleteButton {
display: flex;
align-items: center;
font: normal normal normal 15px/22px 'Montserrat', sans-serif;
background: unset;
border: unset;
&:disabled {
opacity: 0.25;
}
}
}

View file

@ -0,0 +1,88 @@
import React, { useState } from 'react';
import {
FormGroup,
Input,
Label,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
} from 'reactstrap';
import styles from './ConfirmDelete.module.scss';
interface Props {
itemName: string;
deleteFunction(): Promise<void>;
deleteString?: string;
buttonProps?: React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
>;
}
const ConfirmDelete = (props: Props): JSX.Element => {
const {
itemName,
deleteFunction,
deleteString = itemName,
buttonProps,
} = props;
const [isModalOpen, setIsModalOpen] = useState(false);
const toggle = () => setIsModalOpen((open) => !open);
const [deleteType, setDeleteType] = useState('');
const handleDelete = async (): Promise<void> => {
toggle();
setDeleteType('');
await deleteFunction();
};
return (
<div className={styles.confirmDelete}>
<button {...buttonProps} onClick={toggle} className={styles.deleteButton}>
Delete
</button>
<Modal
isOpen={isModalOpen}
toggle={toggle}
centered
className={styles.deleteModal}
>
<ModalHeader toggle={toggle}>
Delete &quot;{itemName}&quot;?
</ModalHeader>
<ModalBody>
<FormGroup>
<Label for="delete">
Please type <b>{deleteString}</b> to confirm deletion
</Label>
<Input
id="delete"
name="delete"
autoComplete="off"
placeholder={deleteString}
value={deleteType}
onChange={(e) => {
setDeleteType(e.target.value);
}}
/>
</FormGroup>
</ModalBody>
<ModalFooter>
<button
disabled={deleteString !== deleteType}
onClick={() => {
void handleDelete();
}}
className={styles.deleteButton}
>
Delete
</button>
</ModalFooter>
</Modal>
</div>
);
};
export default ConfirmDelete;

View file

@ -0,0 +1,25 @@
import React from 'react';
import { Outlet, useNavigation } from 'react-router-dom';
import Sidebar from '../sidebar/Sidebar';
import PageContent from '../page-content/PageContent';
import Loader from '../page-content/loader/Loader';
const Layout = () => {
const navigation = useNavigation();
const isLoading = navigation.state === 'loading';
return (
<>
<Sidebar />
{isLoading ? (
<PageContent>
<Loader hasStartedLoading={true} />
</PageContent>
) : (
<Outlet />
)}
</>
);
};
export default Layout;

View file

@ -0,0 +1,20 @@
@keyframes fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.pageContent {
position: relative;
padding: 98px min(12vw, 144px);
background: #efefef;
width: 100%;
.children {
animation: fade 500ms forwards ease-in-out;
}
}

View file

@ -0,0 +1,20 @@
import React from 'react';
import styles from './PageContent.module.scss';
interface Props {
children: JSX.Element | JSX.Element[];
}
const PageContent = (props: Props) => {
const { children } = props;
return (
<>
<div className={styles.pageContent}>
<div className={styles.children}>{children}</div>
</div>
</>
);
};
export default PageContent;

View file

@ -0,0 +1,51 @@
@keyframes pulse {
0% {
filter: grayscale(0);
}
50% {
filter: grayscale(1);
}
100% {
filter: grayscale(0);
}
}
@keyframes fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.loader {
position: absolute;
top: 0;
left: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: #efefef;
opacity: 0;
transition: opacity 100ms ease-in-out;
pointer-events: none;
&.visible,
&.textVisible {
opacity: 1;
}
&.textVisible {
svg {
animation: fade 600ms forwards ease-in-out,
pulse 1.2s infinite ease-in-out;
}
}
}

View file

@ -0,0 +1,70 @@
import React, { useEffect, useState } from 'react';
import styles from './Loader.module.scss';
interface Props {
isLoading?: boolean;
hasStartedLoading?: boolean;
}
const Loader = (props: Props): JSX.Element => {
const [isLoadingTextShowing, setIsLoadingTextShowing] = useState(false);
const [isLoaderShowing, setIsLoaderShowing] = useState(false);
const [loadingTimeout, setLoadingTimeout] = useState<unknown>();
const { isLoading, hasStartedLoading } = props;
useEffect(() => {
if (hasStartedLoading) {
setIsLoaderShowing(true);
setLoadingTimeout(
setTimeout(() => {
setIsLoadingTextShowing(true);
}, 250),
);
}
}, [hasStartedLoading]);
useEffect(() => {
if (hasStartedLoading && !isLoading) {
setIsLoadingTextShowing(false);
setIsLoaderShowing(false);
clearTimeout(loadingTimeout as NodeJS.Timeout);
}
}, [hasStartedLoading, isLoading, loadingTimeout]);
return (
<div
className={`${styles.loader} ${
isLoadingTextShowing ? styles.textVisible : ''
} ${isLoaderShowing ? styles.visible : ''}`}
>
{isLoadingTextShowing ? (
<>
<svg width="77" height="81" xmlns="http://www.w3.org/2000/svg">
<g>
<path
fill="#ffd600"
d="M2.791 25.101c6.527 3.225 12.977 6.45 19.427 9.675l13.9 6.911a3.78 3.78 0 0 0 1.305.461c.23.077.384.154.614.23l.23.077.23-.077c.23-.077.384-.154.614-.23.498-.145.986-.325 1.459-.538 5.068-2.534 10.136-5.068 15.2-7.525 6.066-2.995 12.209-6.066 18.275-9.061a4.186 4.186 0 0 0 2.615-3.689 3.961 3.961 0 0 0-2.611-3.839l-9.522-4.761C56.695 8.822 48.479 4.829 40.416.913a5.533 5.533 0 0 0-4.146 0c-7.909 3.839-15.972 7.832-23.728 11.672l-9.751 4.837a4.119 4.119 0 0 0-2.688 3.763 4.061 4.061 0 0 0 2.688 3.916m59.817-3.225c-1.613.768-3.148 1.613-4.684 2.38l-2.534 1.229a1063.588 1063.588 0 0 1-16.279 7.986 2.12 2.12 0 0 1-1.536 0c-6.757-3.3-13.515-6.68-20.272-9.982l-4.3-2.15 17.047-8.447c2.611-1.305 5.222-2.611 7.832-3.839.268-.108.556-.16.845-.154 8.063 3.993 16.048 7.986 24.111 11.979l.921.461Z"
/>
<path
fill="#ffd600"
d="M2.637 38.154a19132.396 19132.396 0 0 0 33.326 16.509c.495.224 1.01.403 1.537.535l.691.23.23.077.23-.077.691-.23c.55-.17 1.089-.376 1.613-.614 5.759-2.841 11.518-5.682 17.277-8.6l10.981-5.452c1.766-.845 3.532-1.689 5.222-2.611a3.939 3.939 0 0 0 2-4.838 3.882 3.882 0 0 0-4.454-2.611c-.7.162-1.372.42-2 .768l-3.611 1.846c-8.907 4.377-17.815 8.831-26.722 13.284a2.5 2.5 0 0 1-2.534 0A5805.147 5805.147 0 0 0 6.169 31.013c-2.3-1.152-4.607-.538-5.682 1.536a3.751 3.751 0 0 0-.307 2.995 5.054 5.054 0 0 0 2.457 2.611"
/>
<path
fill="#f5931e"
d="M70.67 56.046a5941.672 5941.672 0 0 0-31.406 15.588 1.662 1.662 0 0 1-1.689 0 8859.35 8859.35 0 0 0-31.56-15.588 4.608 4.608 0 0 0-2.457-.537 3.756 3.756 0 0 0-3.379 3.3 3.847 3.847 0 0 0 2.38 4.3 5055.77 5055.77 0 0 0 33.633 16.664c.447.212.909.392 1.382.538.23.077.384.154.614.23l.23.077.23-.077c.23-.077.384-.154.614-.23.461-.154.921-.307 1.305-.461 11.213-5.531 22.424-11.131 33.635-16.663a5.276 5.276 0 0 0 1.612-1.228 4.037 4.037 0 0 0 0-4.991c-1.229-1.613-3.148-1.92-5.145-.921"
/>
<path
fill="#f5931e"
d="m2.791 50.748 10.827 5.375c7.525 3.686 15.05 7.448 22.575 11.211.69.35 1.453.534 2.227.538.775.005 1.54-.18 2.227-.538 1.766-.921 3.532-1.766 5.3-2.688l7.6-3.763 20.5-10.136a4.1 4.1 0 0 0 2.611-3.686 3.884 3.884 0 0 0-1.689-3.379 4.583 4.583 0 0 0-4.454 0l-31.1 15.434c-.61.385-1.39.385-2 0-8.6-4.377-17.123-8.6-25.724-12.823l-5.368-2.686c-2.457-1.229-4.684-.61-5.759 1.459a3.6 3.6 0 0 0-.307 2.918 4.654 4.654 0 0 0 2.534 2.764"
/>
</g>
</svg>
</>
) : (
<></>
)}
</div>
);
};
export default Loader;

View file

@ -0,0 +1,112 @@
$horizontalPadding: 44px;
$width: 250px;
$totalSidebarSize: calc((#{$horizontalPadding} * 2) + #{$width});
$collapseSize: calc(((#{$horizontalPadding} * 2) + #{$width}) * -1);
$animationSpeed: 250ms;
.sidebar {
position: relative;
z-index: 3;
flex: 0 1 auto;
width: $totalSidebarSize;
height: 100vh;
padding: 48px $horizontalPadding 32px;
background: #fff;
transform: translateX(0);
transition: margin-left $animationSpeed linear;
.toggler {
position: absolute;
top: 10px;
right: -42px;
width: 32px;
height: 32px;
border-top: 16px solid #b2b2b2;
border-left: 16px solid #b2b2b2;
transform: rotate(0deg);
cursor: pointer;
opacity: 0.25;
transition: transform calc(#{$animationSpeed} - 100ms) ease-in-out,
opacity calc(#{$animationSpeed} - 100ms) ease-in-out;
&:hover {
transform: rotate(45deg);
opacity: 1;
}
}
&.collapsed {
margin-left: $collapseSize;
.toggler {
transform: rotate(90deg);
&:hover {
transform: rotate(45deg);
}
}
}
.logo {
margin-bottom: 48px;
height: 40px;
max-height: 50px;
max-width: 200px;
object-fit: contain;
}
.links {
margin-top: 48px;
img {
height: 40px;
object-fit: contain;
}
.spacer {
height: 20px;
margin-bottom: 16px;
}
.link,
.spacer {
display: flex;
align-items: center;
text-decoration: none;
img {
flex: 0 0 20px;
width: 20px;
object-fit: contain;
}
span {
margin-left: 15px;
color: #707070;
font: normal normal 300 17px/20px Montserrat;
border-bottom: 1px solid #fff0;
transition: border-bottom 150ms ease-in-out;
}
&.active {
img {
/** Since its not an svg in DOM we cant edit fill */
filter: brightness(0);
}
span {
color: #000;
}
pointer-events: none;
user-select: none;
}
&:hover {
span {
border-bottom: thin solid #707070;
}
}
}
}
}

View file

@ -0,0 +1,74 @@
import React, { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import styles from './Sidebar.module.scss';
const Sidebar = (): JSX.Element => {
const location = useLocation();
const [isToggled, setIsToggled] = useState(false);
useEffect(() => {
if (window.innerWidth < 400) {
setIsToggled(true);
}
}, []);
return (
<div className={`${styles.sidebar} ${isToggled ? styles.collapsed : ''}`}>
<div
className={styles.toggler}
onClick={() => {
setIsToggled((toggled) => !toggled);
}}
></div>
<img className={styles.logo} src="/logo.png" alt="Baseline" />
<div className={styles.links}>
<Link
to="/dashboard"
className={`${styles.link} ${
location.pathname === '/dashboard' ? styles.active : ''
}`}
>
<img src="/icons/home.svg" alt="Home" />
<span>Dashboard</span>
</Link>
<div className={styles.spacer} />
<Link
to="/chart"
className={`${styles.link} ${
location.pathname === '/chart' ? styles.active : ''
}`}
>
<img src="/icons/users.svg" alt="Charts" />
<span>Charts</span>
</Link>
<Link
to="/admins"
className={`${styles.link} ${
location.pathname === '/admins' ? styles.active : ''
}`}
>
<img src="/icons/users.svg" alt="Admins" />
<span>Admins</span>
</Link>
<div className={styles.spacer} />
<Link
to="/settings"
className={`${styles.link} ${
location.pathname === '/settings' ? styles.active : ''
}`}
>
<img src="/icons/gear.svg" alt="Settings" />
<span>Account Settings</span>
</Link>
</div>
</div>
);
};
export default Sidebar;

View file

@ -0,0 +1,39 @@
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #fff;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
.loader-container div {
width: 50px;
height: 50px;
background: #a0a0a0;
border-radius: 30px;
}
.app {
display: flex;
min-height: 100vh;
overflow: hidden;
flex-direction: row;
flex: 1 1 auto;
> div:last-of-type {
flex: 1 1 auto;
min-width: min(100vw, 390px);
}
}
[id=root] {
display: flex;
flex-direction: row;
flex: 1 1 auto;
}

View file

@ -0,0 +1,11 @@
import React from 'react';
import * as ReactDOM from 'react-dom/client';
import './styles/fonts.scss';
import './index.scss';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

1
packages/admin/src/react-app-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="react-scripts" />

View file

@ -0,0 +1,90 @@
$xl: 1200px;
$lg: 992px;
$md: 768px;
$sm: 576px;
$animationSpeed: 150ms;
$breakSize: $md;
@mixin genericButton {
@include smallFont(normal, 600);
display: inline-block;
padding: 12px 108px;
color: #fff;
text-decoration: none;
background: #3a3838;
border: 2px solid #3a3838;
border-radius: 32px;
transition: background-color $animationSpeed ease-in-out,
color $animationSpeed ease-in-out;
&:hover {
color: #3a3838;
background: #fff;
}
}
@mixin tinyFont(
$style: normal,
$weight: normal,
$fontFace: 'Montserrat',
$fallback: sans-serif
) {
font: $style normal $weight 12px/20px $fontFace, $fallback;
@media screen and (max-width: $breakSize) {
font: $style normal $weight 8px/16px $fontFace, $fallback;
}
}
@mixin smallFont(
$style: normal,
$weight: normal,
$fontFace: 'Montserrat',
$fallback: sans-serif
) {
font: $style normal $weight 16px/24px $fontFace, $fallback;
@media screen and (max-width: $breakSize) {
font: $style normal $weight 12px/20px $fontFace, $fallback;
}
}
@mixin mediumFont(
$style: normal,
$weight: normal,
$fontFace: 'Montserrat',
$fallback: sans-serif
) {
font: $style normal $weight 24px/32px $fontFace, $fallback;
@media screen and (max-width: $breakSize) {
font: $style normal $weight 16px/24px $fontFace, $fallback;
}
}
@mixin largeFont(
$style: normal,
$weight: normal,
$fontFace: 'Montserrat',
$fallback: sans-serif
) {
font: $style normal $weight 40px/49px $fontFace, $fallback;
@media screen and (max-width: $breakSize) {
font: $style normal $weight 24px/32px $fontFace, $fallback;
}
}
@mixin hugeFont(
$style: normal,
$weight: normal,
$fontFace: 'Montserrat',
$fallback: sans-serif
) {
font: $style normal $weight 72px/88px $fontFace, $fallback;
@media screen and (max-width: $breakSize) {
font: $style normal $weight 40px/49px $fontFace, $fallback;
}
}

View file

@ -0,0 +1,2 @@
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;800;900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;600&display=swap');

View file

@ -0,0 +1,75 @@
import useSWR, { mutate } from 'swr';
import { getAllCharts, getChart } from '@baseline/client-api/chart';
import { Chart } from '@baseline/types/chart';
export const useCharts = () => {
const { data, error, isLoading, isValidating } = useSWR<Chart[], unknown>(
`chart/list`,
() => getAllCharts(),
);
return {
charts: data,
isLoading,
error,
isValidating,
};
};
export const useChart = (chartId: string) => {
const { data, error, isLoading, isValidating } = useSWR<Chart, unknown>(
chartId ? `chart/${chartId}` : null,
() => getChart(chartId),
);
return {
chart: data,
isLoading,
error,
isValidating,
};
};
export const onChartCreated = async (chart: Chart) => {
await Promise.all([
mutate<Chart[]>(
`chart/list`,
(existingCharts) => [...(existingCharts || []), chart],
{
revalidate: false,
},
),
mutate<Chart>(`chart/${chart.chartId}`, chart),
]);
};
export const onChartUpdated = async (chart: Chart) => {
await Promise.all([
mutate<Chart[]>(
`chart/list`,
(existingCharts) =>
(existingCharts || []).map((chartExisting) =>
chartExisting.chartId === chart.chartId ? chart : chartExisting,
),
{
revalidate: false,
},
),
mutate<Chart>(`chart/${chart.chartId}`, chart),
]);
};
export const onChartDeleted = async (chart: Chart) => {
await Promise.all([
mutate<Chart[]>(
`chart/list`,
(existingCharts) =>
(existingCharts || []).filter(
(chartExisting) => chartExisting.chartId !== chart.chartId,
),
{
revalidate: false,
},
),
]);
};

5
packages/admin/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
/// <reference types="vite/client" />
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View file

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

View file

@ -0,0 +1,41 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import EnvironmentPlugin from 'vite-plugin-environment';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
EnvironmentPlugin([
'REACT_APP_APP_NAME',
'REACT_APP_AWS_PROFILE',
'REACT_APP_API_URL',
'REACT_APP_COGNITO_IDENTITY_POOL_ID',
'REACT_APP_COGNITO_USER_POOL_ID',
'REACT_APP_COGNITO_USER_POOL_WEB_CLIENT_ID',
]),
],
envPrefix: 'REACT_APP_',
define: {},
resolve: {
alias: {
'./runtimeConfig': './runtimeConfig.browser',
},
},
esbuild: {
minifyWhitespace: true,
treeShaking: true,
},
build: {
outDir: '.dist',
minify: 'esbuild',
chunkSizeWarningLimit: 1500,
rollupOptions: {
output: {
manualChunks: {
amplify: ['@aws-amplify/ui-react', 'aws-amplify'],
},
},
},
},
});