├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── NOTICE ├── README.md ├── THIRD-PARTY-LICENSES.txt ├── serverless ├── README.md ├── lambda │ ├── app.js │ └── package.json └── template.yaml ├── simple-chat-demo.png └── web-ui ├── .gitignore ├── README.md ├── package.json ├── public ├── favicon.ico ├── index.html ├── manifest.json └── robots.txt └── src ├── components ├── App.css ├── App.js ├── App.test.js ├── chat │ ├── Chat.css │ ├── Chat.jsx │ └── SignIn.jsx └── videoPlayer │ ├── VideoPlayer.css │ └── VideoPlayer.jsx ├── config.js ├── index.css ├── index.js ├── serviceWorker.js └── setupTests.js /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # production 5 | /build 6 | 7 | # misc 8 | .DS_Store 9 | .env.local 10 | .env.development.local 11 | .env.test.local 12 | .env.production.local 13 | 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *master* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | 61 | We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 4 | software and associated documentation files (the "Software"), to deal in the Software 5 | without restriction, including without limitation the rights to use, copy, modify, 6 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 10 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 11 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 12 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 13 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 14 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Amazon IVS Simple Chat Demo 2 | Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ⚠️ IMPORTANT ⚠️ This repository is no longer actively maintained and will be archived at the end of 2022 2 | 3 | For a more scalable multi-platform solution using [Amazon IVS Chat](https://aws.amazon.com/ivs/features/chat), refer to the following repositories: 4 | 5 | * [Amazon IVS Chat Web Demo](https://github.com/aws-samples/amazon-ivs-chat-web-demo) 6 | * [Amazon IVS Chat iOS Demo](https://github.com/aws-samples/amazon-ivs-chat-for-ios-demo) 7 | * [Amazon IVS Chat Android Demo](https://github.com/aws-samples/amazon-ivs-chat-for-android-demo) 8 | 9 | ## Amazon IVS Simple Chat demo 10 | 11 | A demo web application intended as an educational tool for demonstrating how you can build a very simple Chat backend. In conjunction with Amazon IVS, it can be used to build a compelling customer experience for live streams with chat use-cases. 12 | 13 | **This project is intended for education purposes only and not for production usage.** 14 | 15 | This is a serverless web application, leveraging [Amazon IVS](https://aws.amazon.com/ivs/), [AWS Lambda](https://aws.amazon.com/lambda/), and WebSockets. The web user interface is a [single page application](https://en.wikipedia.org/wiki/Single-page_application) built using [responsive web design](https://en.wikipedia.org/wiki/Responsive_web_design) frameworks and techniques, producing a native app-like experience tailored to the user's device. 16 | 17 | 18 | The demo showcases how you can implement a simple chat client next to an Amazon IVS stream. Viewers are asked to enter their name the first time they begin chatting. Messages are sent in the format `` `` as part of each chat "bubble". Chat users can send plain text messages, text links, and emojis. Chat messages have a character limit of 510 characters. 19 | 20 | ## Getting Started 21 | 22 | ***IMPORTANT NOTE:** Deploying this demo application in your AWS account will create and consume AWS resources, which will cost money.* 23 | 24 | This demo is comprised of two parts: `serverless` (the demo backend) and `web-ui` (the demo frontend). 25 | 26 | 1. If you do not have an AWS account, please see [How do I create and activate a new Amazon Web Services account?](https://aws.amazon.com/premiumsupport/knowledge-center/create-and-activate-aws-account/) 27 | 2. Log into the [AWS console](https://console.aws.amazon.com/) if you are not already. Note: If you are logged in as an IAM user, ensure your account has permissions to create and manage the necessary resources and components for this application. 28 | 3. [Test locally or deploy](./serverless/README.md) to your AWS account. The CloudFormation template will automate the serverless backend and Amazon IVS channel creation. 29 | 30 | ## Known issues and limitations 31 | * The application was written for demonstration purposes and not for production use. 32 | * Currently only tested in the us-west-2 (Oregon) region. Additional regions may be supported depending on service availability. 33 | 34 | ## About Amazon IVS 35 | Amazon Interactive Video Service (Amazon IVS) is a managed live streaming solution that is quick and easy to set up, and ideal for creating interactive video experiences. [Learn more](https://aws.amazon.com/ivs/). 36 | 37 | * [Amazon IVS docs](https://docs.aws.amazon.com/ivs/) 38 | * [User Guide](https://docs.aws.amazon.com/ivs/latest/userguide/) 39 | * [API Reference](https://docs.aws.amazon.com/ivs/latest/APIReference/) 40 | * [Setting Up for Streaming with Amazon Interactive Video Service](https://aws.amazon.com/blogs/media/setting-up-for-streaming-with-amazon-ivs/) 41 | * [Learn more about Amazon IVS on IVS.rocks](https://ivs.rocks/) 42 | * [View more demos like this](https://ivs.rocks/examples) 43 | 44 | ## Security 45 | 46 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 47 | 48 | ## License 49 | 50 | This library is licensed under the MIT-0 License. See the LICENSE file. 51 | -------------------------------------------------------------------------------- /THIRD-PARTY-LICENSES.txt: -------------------------------------------------------------------------------- 1 | ** React; version 16.13.1 -- https://github.com/facebook/react 2 | 3 | MIT License 4 | 5 | Copyright (c) Facebook, Inc. and its affiliates. 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /serverless/README.md: -------------------------------------------------------------------------------- 1 | # Deployment instructions for the demo backend and Amazon IVS channel 2 | 3 | Deploy the simple chat backend, create a new Amazon IVS channel using AWS CloudFormation, and retrieve everything you need to configure the demo. 4 | 5 | ## Prerequisites 6 | 7 | * Access to AWS Account with permission to create IAM role, and Lambda. 8 | * [AWS CLI Version 2](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) 9 | * [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html) 10 | 11 | ## Deploy from your local machine 12 | 13 | Before you start, run the following command to make sure you're in the correct AWS account (or configure as needed): 14 | ``` 15 | aws configure 16 | ``` 17 | For additional help on configuring, please see https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-configure.html 18 | 19 | ### 1. Create an S3 bucket 20 | 21 | * Replace `` with your bucket name. 22 | * Replace `` with your region name. 23 | 24 | ``` 25 | aws s3api create-bucket --bucket --region \ 26 | --create-bucket-configuration LocationConstraint= 27 | ``` 28 | 29 | ### 2. Pack template with SAM 30 | 31 | ``` 32 | sam package \ 33 | --template-file template.yaml \ 34 | --output-template-file packaged.yaml \ 35 | --s3-bucket 36 | ``` 37 | DO NOT run the output from above command, proceed to next step. 38 | 39 | ### 3. Deploy Cloudformation with SAM 40 | 41 | Replace `` with your stack name. 42 | 43 | ``` 44 | sam deploy \ 45 | --template-file packaged.yaml \ 46 | --stack-name \ 47 | --capabilities CAPABILITY_IAM 48 | ``` 49 | 50 | On completion, save the following values: 51 | 1. `WebSocketURI`, used in the demo configuration file (`config.js`), to send/receive chat messages 52 | 2. `ChannelIngestEndpoint`, to be used in your broadcasting software (ex. OBS) 53 | 3. `StreamKey`, to be used in your broadcasting software (ex. OBS) 54 | 4. `ChannelPlaybackUrl`, used in the demo configuration file (`config.js`), to load your Amazon IVS stream in the video player 55 | 56 | 57 | If needed, to retrieve Cloudformation stack outputs again, run below command: 58 | ``` 59 | aws cloudformation describe-stacks --stack-name 60 | 61 | aws cloudformation describe-stacks \ 62 | --stack-name --query 'Stacks[].Outputs' 63 | ``` 64 | 65 | ### 4. (optional) Testing the chat API 66 | 67 | To test the WebSocket API, you can use [wscat](https://github.com/websockets/wscat), an open-source command line tool. 68 | 69 | 1. [Install NPM](https://www.npmjs.com/get-npm). 70 | 2. Install wscat: 71 | ``` bash 72 | $ npm install -g wscat 73 | ``` 74 | 3. On the console, connect to your published API endpoint by executing the following command: 75 | 76 | Replace `` with your WebSocketServer URL created when deploying with cloudformation. 77 | 78 | ``` bash 79 | $ wscat -c 80 | ``` 81 | 4. To test the sendMessage function, send a JSON message like the following example. The Lambda function sends it back using the callback URL: 82 | 83 | Replace `` with your WebSocketServer URL created when deploying with cloudformation. 84 | 85 | ``` bash 86 | $ wscat -c 87 | connected (press CTRL+C to quit) 88 | > {"action":"sendmessage", "data":"hello world"} 89 | < hello world 90 | ``` 91 | 92 | ### 5. Configure the Simple Chat demo frontend 93 | 94 | Follow the [detailed instructions](../web-ui) on how to get the frontend up and running. 95 | 96 | ### 6. (optional) Clean Up 97 | 98 | 1. Delete Cloudformation stack: 99 | ``` 100 | aws cloudformation delete-stack --stack-name 101 | ``` 102 | 103 | 2. Remove files in S3 bucket 104 | ``` 105 | aws s3 rm s3:// --recursive 106 | ``` 107 | 108 | 3. Delete S3 bucket 109 | ``` 110 | aws s3api delete-bucket --bucket --region 111 | ``` 112 | -------------------------------------------------------------------------------- /serverless/lambda/app.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | const AWS = require('aws-sdk'); 5 | const ddb = new AWS.DynamoDB.DocumentClient({ apiVersion: '2012-08-10', region: process.env.AWS_REGION }); 6 | const { TABLE_NAME } = process.env; 7 | 8 | exports.onConnect = async event => { 9 | const putParams = { 10 | TableName: TABLE_NAME, 11 | Item: { 12 | connectionId: event.requestContext.connectionId 13 | } 14 | }; 15 | 16 | try { 17 | await ddb.put(putParams).promise(); 18 | } catch (err) { 19 | return { statusCode: 500, body: 'Failed to connect: ' + JSON.stringify(err) }; 20 | } 21 | 22 | return { statusCode: 200, body: 'Connected.' }; 23 | }; 24 | 25 | exports.sendMessage = async event => { 26 | let connectionData; 27 | 28 | try { 29 | connectionData = await ddb.scan({ TableName: TABLE_NAME, ProjectionExpression: 'connectionId' }).promise(); 30 | } catch (e) { 31 | return { statusCode: 500, body: e.stack }; 32 | } 33 | 34 | const apigwManagementApi = new AWS.ApiGatewayManagementApi({ 35 | apiVersion: '2018-11-29', 36 | endpoint: event.requestContext.domainName + '/' + event.requestContext.stage 37 | }); 38 | 39 | const postData = JSON.parse(event.body).data; 40 | 41 | const postCalls = connectionData.Items.map(async ({ connectionId }) => { 42 | try { 43 | await apigwManagementApi.postToConnection({ ConnectionId: connectionId, Data: postData }).promise(); 44 | } catch (e) { 45 | if (e.statusCode === 410) { 46 | console.log(`Found stale connection, deleting ${connectionId}`); 47 | await ddb.delete({ TableName: TABLE_NAME, Key: { connectionId } }).promise(); 48 | } else { 49 | throw e; 50 | } 51 | } 52 | }); 53 | 54 | try { 55 | await Promise.all(postCalls); 56 | } catch (e) { 57 | return { statusCode: 500, body: e.stack }; 58 | } 59 | 60 | return { statusCode: 200, body: 'Data sent.' }; 61 | }; 62 | 63 | exports.onDisconnect = async event => { 64 | const deleteParams = { 65 | TableName: TABLE_NAME, 66 | Key: { 67 | connectionId: event.requestContext.connectionId 68 | } 69 | }; 70 | 71 | try { 72 | await ddb.delete(deleteParams).promise(); 73 | } catch (err) { 74 | return { statusCode: 500, body: 'Failed to disconnect: ' + JSON.stringify(err) }; 75 | } 76 | 77 | return { statusCode: 200, body: 'Disconnected.' }; 78 | }; 79 | 80 | -------------------------------------------------------------------------------- /serverless/lambda/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-chat", 3 | "version": "1.0.0", 4 | "description": "onConnect, sendMessage and onDisconnect example for WebSockets on API Gateway", 5 | "main": "src/app.js", 6 | "author": " ", 7 | "license": " ", 8 | "dependencies": { 9 | "aws-sdk": "^2.690.0" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /serverless/template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | Description: Amazon IVS Simple Chat demo 4 | 5 | Parameters: 6 | TableName: 7 | Type: String 8 | Default: 'simplechat_connections' 9 | Description: (Required) The name of the new DynamoDB to store connection identifiers for each connected clients. Minimum 3 characters 10 | MinLength: 3 11 | MaxLength: 50 12 | AllowedPattern: ^[A-Za-z_]+$ 13 | ConstraintDescription: 'Required. Can be characters and underscore only. No numbers or special characters allowed.' 14 | 15 | Resources: 16 | # Amazon IVS 17 | Channel: 18 | Type: AWS::IVS::Channel 19 | Properties: 20 | Name: simple-chat-demo 21 | StreamKey: 22 | Type: AWS::IVS::StreamKey 23 | Properties: 24 | ChannelArn: !Ref Channel 25 | 26 | # Chat serverless backend 27 | SimpleChatWebSocket: 28 | Type: AWS::ApiGatewayV2::Api 29 | Properties: 30 | Name: SimpleChatWebSocket 31 | ProtocolType: WEBSOCKET 32 | RouteSelectionExpression: "$request.body.action" 33 | ConnectIntegration: 34 | Type: AWS::ApiGatewayV2::Integration 35 | Properties: 36 | ApiId: !Ref SimpleChatWebSocket 37 | Description: Connect Integration 38 | IntegrationType: AWS_PROXY 39 | IntegrationUri: 40 | Fn::Sub: 41 | arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${OnConnectFunction.Arn}/invocations 42 | ConnectRoute: 43 | Type: AWS::ApiGatewayV2::Route 44 | Properties: 45 | ApiId: !Ref SimpleChatWebSocket 46 | RouteKey: $connect 47 | AuthorizationType: NONE 48 | OperationName: ConnectRoute 49 | Target: !Join 50 | - '/' 51 | - - 'integrations' 52 | - !Ref ConnectIntegration 53 | SendMessageIntegration: 54 | Type: AWS::ApiGatewayV2::Integration 55 | Properties: 56 | ApiId: !Ref SimpleChatWebSocket 57 | Description: Send Integration 58 | IntegrationType: AWS_PROXY 59 | IntegrationUri: 60 | Fn::Sub: 61 | arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${SendMessageFunction.Arn}/invocations 62 | SendRoute: 63 | Type: AWS::ApiGatewayV2::Route 64 | Properties: 65 | ApiId: !Ref SimpleChatWebSocket 66 | RouteKey: sendmessage 67 | AuthorizationType: NONE 68 | OperationName: SendRoute 69 | Target: !Join 70 | - '/' 71 | - - 'integrations' 72 | - !Ref SendMessageIntegration 73 | DisconnectIntegration: 74 | Type: AWS::ApiGatewayV2::Integration 75 | Properties: 76 | ApiId: !Ref SimpleChatWebSocket 77 | Description: Disconnect Integration 78 | IntegrationType: AWS_PROXY 79 | IntegrationUri: 80 | Fn::Sub: 81 | arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${OnDisconnectFunction.Arn}/invocations 82 | DisconnectRoute: 83 | Type: AWS::ApiGatewayV2::Route 84 | Properties: 85 | ApiId: !Ref SimpleChatWebSocket 86 | RouteKey: $disconnect 87 | AuthorizationType: NONE 88 | OperationName: DisconnectRoute 89 | Target: !Join 90 | - '/' 91 | - - 'integrations' 92 | - !Ref DisconnectIntegration 93 | Deployment: 94 | Type: AWS::ApiGatewayV2::Deployment 95 | DependsOn: 96 | - ConnectRoute 97 | - SendRoute 98 | - DisconnectRoute 99 | Properties: 100 | ApiId: !Ref SimpleChatWebSocket 101 | Stage: 102 | Type: AWS::ApiGatewayV2::Stage 103 | Properties: 104 | StageName: Prod 105 | Description: Prod Stage 106 | DeploymentId: !Ref Deployment 107 | ApiId: !Ref SimpleChatWebSocket 108 | ConnectionsTable: 109 | Type: AWS::DynamoDB::Table 110 | Properties: 111 | AttributeDefinitions: 112 | - AttributeName: "connectionId" 113 | AttributeType: "S" 114 | KeySchema: 115 | - AttributeName: "connectionId" 116 | KeyType: "HASH" 117 | ProvisionedThroughput: 118 | ReadCapacityUnits: 5 119 | WriteCapacityUnits: 5 120 | SSESpecification: 121 | SSEEnabled: True 122 | TableName: !Ref TableName 123 | OnConnectFunction: 124 | Type: AWS::Serverless::Function 125 | Properties: 126 | CodeUri: lambda/ 127 | Handler: app.onConnect 128 | MemorySize: 256 129 | Runtime: nodejs12.x 130 | Environment: 131 | Variables: 132 | TABLE_NAME: !Ref TableName 133 | Policies: 134 | - DynamoDBCrudPolicy: 135 | TableName: !Ref TableName 136 | SendMessageFunction: 137 | Type: AWS::Serverless::Function 138 | Properties: 139 | CodeUri: lambda/ 140 | Handler: app.sendMessage 141 | MemorySize: 256 142 | Runtime: nodejs12.x 143 | Environment: 144 | Variables: 145 | TABLE_NAME: !Ref TableName 146 | Policies: 147 | - DynamoDBCrudPolicy: 148 | TableName: !Ref TableName 149 | - Statement: 150 | - Effect: Allow 151 | Action: 152 | - 'execute-api:ManageConnections' 153 | Resource: 154 | - !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${SimpleChatWebSocket}/*' 155 | SendMessagePermission: 156 | Type: AWS::Lambda::Permission 157 | DependsOn: 158 | - SimpleChatWebSocket 159 | Properties: 160 | Action: lambda:InvokeFunction 161 | FunctionName: !Ref SendMessageFunction 162 | Principal: apigateway.amazonaws.com 163 | OnConnectPermission: 164 | Type: AWS::Lambda::Permission 165 | DependsOn: 166 | - SimpleChatWebSocket 167 | Properties: 168 | Action: lambda:InvokeFunction 169 | FunctionName: !Ref OnConnectFunction 170 | Principal: apigateway.amazonaws.com 171 | OnDisconnectFunction: 172 | Type: AWS::Serverless::Function 173 | Properties: 174 | CodeUri: lambda/ 175 | Handler: app.onDisconnect 176 | MemorySize: 256 177 | Runtime: nodejs12.x 178 | Environment: 179 | Variables: 180 | TABLE_NAME: !Ref TableName 181 | Policies: 182 | - DynamoDBCrudPolicy: 183 | TableName: !Ref TableName 184 | OnDisconnectPermission: 185 | Type: AWS::Lambda::Permission 186 | DependsOn: 187 | - SimpleChatWebSocket 188 | Properties: 189 | Action: lambda:InvokeFunction 190 | FunctionName: !Ref OnDisconnectFunction 191 | Principal: apigateway.amazonaws.com 192 | 193 | Outputs: 194 | WebSocketURI: 195 | Description: "The WSS Protocol URI to connect to" 196 | Value: !Join [ '', [ 'wss://', !Ref SimpleChatWebSocket, '.execute-api.',!Ref 'AWS::Region','.amazonaws.com/',!Ref 'Stage'] ] 197 | ChannelArn: 198 | Description: "Amazon IVS channel ARN:" 199 | Value: !Ref Channel 200 | ChannelIngestEndpoint: 201 | Description: "Amazon IVS ingest server:" 202 | Value: !Join [ '', [ 'rtmps://', !GetAtt Channel.IngestEndpoint, ':443/app/'] ] 203 | StreamKey: 204 | Description: "Amazon IVS stream key:" 205 | Value: !GetAtt StreamKey.Value 206 | ChannelPlaybackUrl: 207 | Description: "Amazon IVS playback URL:" 208 | Value: !GetAtt Channel.PlaybackUrl 209 | -------------------------------------------------------------------------------- /simple-chat-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-simple-chat-web-demo/714a6e0744f3fc38c36406403f9e2c0e801951a0/simple-chat-demo.png -------------------------------------------------------------------------------- /web-ui/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # production 7 | /build 8 | 9 | #misc 10 | .DS_Store -------------------------------------------------------------------------------- /web-ui/README.md: -------------------------------------------------------------------------------- 1 | # Simple Chat Web Demo frontend 2 | 3 | ## Prerequisites 4 | 5 | * [NodeJS](https://nodejs.org/) 6 | * Npm is installed with Node.js 7 | * Amazon IVS Simple Chat demo backend (see README.md in the `serverless` folder for details on the configuration) 8 | 9 | ## Running the demo 10 | 11 | To get the web demo running, follow these instructions: 12 | 13 | 1. [Install NodeJS](https://nodejs.org/). Download latest LTS version ("Recommended for Most Users") 14 | 2. Navigate to the web-ui project directory on your local computer 15 | 3. Run: npm install 16 | 4. Run: npm start 17 | 5. Open your web-browser and enter the URL: http://localhost:3000/ 18 | 19 | ## Configuration 20 | 21 | The following entries in `src/config.js` (inside the web-ui project directory) are used to configure the live video player and the chat websocket address. Both values will be made available to you when setting up the serverless backend using AWS CloudFormation. [Show me how](../serverless). 22 | 23 | * `PLAYBACK_URL` 24 | - Amazon IVS live video stream to play inside the video player 25 | 26 | * `CHAT_WEBSOCKET` 27 | - WebSocket address 28 | 29 | ## Limitations 30 | 31 | * No user authentication 32 | * No history 33 | * No name validation 34 | * Name cannot be changed once set 35 | * Name is not saved/not persistent (ie. reloading the page would go back to initial state) 36 | 37 | -------------------------------------------------- 38 | 39 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 40 | 41 | ## Available Scripts 42 | 43 | In the project directory, you can run: 44 | 45 | ### `npm start` 46 | 47 | Runs the app in the development mode.
48 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 49 | 50 | The page will reload if you make edits.
51 | You will also see any lint errors in the console. 52 | 53 | ### `npm test` 54 | 55 | Launches the test runner in the interactive watch mode.
56 | See the section about [running tests](https://create-react-app.dev/docs/running-tests/) for more information. 57 | 58 | ### `npm run build` 59 | 60 | Builds the app for production to the `build` folder.
61 | It correctly bundles React in production mode and optimizes the build for the best performance. 62 | 63 | The build is minified and the filenames include the hashes.
64 | Your app is ready to be deployed! 65 | 66 | See the section about [deployment](https://create-react-app.dev/docs/deployment/) for more information. 67 | 68 | ## Learn More 69 | 70 | You can learn more in the [Create React App documentation](https://create-react-app.dev/docs/getting-started/). 71 | To learn React, check out the [React documentation](https://reactjs.org/). 72 | 73 | ### Code Splitting 74 | 75 | https://create-react-app.dev/docs/code-splitting/ 76 | 77 | ### Analyzing the Bundle Size 78 | 79 | https://create-react-app.dev/docs/analyzing-the-bundle-size/ 80 | 81 | ### Making a Progressive Web App 82 | 83 | https://create-react-app.dev/docs/making-a-progressive-web-app/ 84 | 85 | ### Advanced Configuration 86 | 87 | https://create-react-app.dev/docs/advanced-configuration/ 88 | 89 | ### Deployment 90 | 91 | https://create-react-app.dev/docs/deployment/ 92 | 93 | ### `npm run build` fails to minify 94 | 95 | https://create-react-app.dev/docs/troubleshooting/#npm-run-build-fails-to-minify 96 | -------------------------------------------------------------------------------- /web-ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-chat-demo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.15.1", 7 | "@testing-library/react": "^12.1.2", 8 | "@testing-library/user-event": "^13.5.0", 9 | "node-forge": ">=0.10.0", 10 | "react": "^17.0.2", 11 | "react-dom": "^17.0.2", 12 | "react-scripts": "5.0.0" 13 | }, 14 | "scripts": { 15 | "start": "react-scripts start", 16 | "build": "react-scripts build", 17 | "test": "react-scripts test" 18 | }, 19 | "eslintConfig": { 20 | "extends": "react-app" 21 | }, 22 | "browserslist": { 23 | "production": [ 24 | ">0.2%", 25 | "not dead", 26 | "not op_mini all" 27 | ], 28 | "development": [ 29 | "last 1 chrome version", 30 | "last 1 firefox version", 31 | "last 1 safari version" 32 | ] 33 | }, 34 | "homepage": "/" 35 | } 36 | -------------------------------------------------------------------------------- /web-ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-simple-chat-web-demo/714a6e0744f3fc38c36406403f9e2c0e801951a0/web-ui/public/favicon.ico -------------------------------------------------------------------------------- /web-ui/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 | 20 | 21 | 30 | IVS-Simple Chat Web 31 | 32 | 33 | 34 |
35 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /web-ui/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "IVS-Simple Chat Web Demo", 3 | "name": "A demo web application intended as an educational tool for demonstrating how you can build a very simple live Chat experience, in conjunction with Amazon IVS.", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /web-ui/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /web-ui/src/components/App.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-simple-chat-web-demo/714a6e0744f3fc38c36406403f9e2c0e801951a0/web-ui/src/components/App.css -------------------------------------------------------------------------------- /web-ui/src/components/App.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React from 'react'; 5 | 6 | import Chat from './chat/Chat'; 7 | 8 | function App() { 9 | return ( 10 |
11 | 12 |
13 | ); 14 | } 15 | 16 | export default App; 17 | -------------------------------------------------------------------------------- /web-ui/src/components/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /web-ui/src/components/chat/Chat.css: -------------------------------------------------------------------------------- 1 | /* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. */ 2 | /* SPDX-License-Identifier: MIT-0 */ 3 | 4 | :root { 5 | --section-max-width: auto; 6 | --color--primary: #000; 7 | --color-bg-base: #F7F7F7; 8 | --color-bg-modal-overlay: rgba(185,185,192,.9); 9 | } 10 | 11 | .main { 12 | height: calc(100vh - var(--header-height)); 13 | } 14 | 15 | .content-wrapper { 16 | max-width: 1280px; 17 | background: #FFF; 18 | box-shadow: 0 6px 30px rgba(0,0,0,.08); 19 | display: flex; 20 | flex-direction: row; 21 | position: relative; 22 | align-items: stretch; 23 | margin: 0 auto; 24 | top: calc(50% - 250px); 25 | } 26 | 27 | .player-wrapper { 28 | width: 100%; 29 | background: black; 30 | position: relative; 31 | overflow: hidden; 32 | } 33 | 34 | .aspect-spacer { 35 | padding-bottom: 56.25%; 36 | } 37 | 38 | .el-player { 39 | width: 100%; 40 | height: 100%; 41 | position: absolute; 42 | top: 0; 43 | background: #000; 44 | } 45 | 46 | .col-wrapper { 47 | width: 400px; 48 | background: #FFF; 49 | flex-shrink: 0; 50 | align-self: stretch; 51 | position: relative; 52 | } 53 | 54 | .chat-wrapper { 55 | left: 1rem; 56 | right: 1rem; 57 | overflow: hidden; 58 | } 59 | 60 | .chat-wrapper .messages { 61 | height: 100%; 62 | width: 100%; 63 | overflow-y: auto; 64 | display: flex; 65 | flex-direction: column; 66 | align-items: flex-start; 67 | } 68 | 69 | .composer button.btn { 70 | margin-bottom: 0; 71 | } 72 | 73 | .hidden { 74 | display: none !important; 75 | } 76 | 77 | .btn:disabled { 78 | opacity: .5; 79 | background: var(--color-bg-button-primary-default); 80 | } 81 | 82 | @media (max-width: 1080px) { 83 | .content-wrapper { 84 | height: 100%; 85 | flex-direction: column; 86 | top: 0; 87 | } 88 | .col-wrapper { 89 | width: 100%; 90 | height: auto; 91 | flex-grow: 1; 92 | } 93 | } -------------------------------------------------------------------------------- /web-ui/src/components/chat/Chat.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React, { useEffect, useState, createRef } from 'react'; 5 | import * as config from '../../config'; 6 | 7 | // Components 8 | import VideoPlayer from '../videoPlayer/VideoPlayer'; 9 | import SignIn from './SignIn'; 10 | 11 | // Styles 12 | import './Chat.css'; 13 | 14 | const Chat = () => { 15 | const [showSignIn, setShowSignIn] = useState(false); 16 | const [username, setUsername] = useState(''); 17 | const [message, setMessage] = useState(''); 18 | const [messages, setMessages] = useState([]); 19 | const [connection, setConnection] = useState(null); 20 | 21 | const chatRef = createRef(); 22 | const messagesEndRef = createRef(); 23 | 24 | useEffect(() => { 25 | 26 | const initConnection = async () => { 27 | const connectionInit = new WebSocket(config.CHAT_WEBSOCKET); 28 | connectionInit.onopen = (event) => { 29 | console.log("WebSocket is now open."); 30 | }; 31 | 32 | connectionInit.onclose = (event) => { 33 | console.log("WebSocket is now closed."); 34 | }; 35 | 36 | connectionInit.onerror = (event) => { 37 | console.error("WebSocket error observed:", event); 38 | }; 39 | 40 | connectionInit.onmessage = (event) => { 41 | // append received message from the server to the DOM element 42 | const data = event.data.split('::'); 43 | const username = data[0]; 44 | const message = data.slice(1).join('::'); // in case the message contains the separator '::' 45 | 46 | const newMessage = { 47 | timestamp: Date.now(), 48 | username, 49 | message 50 | } 51 | 52 | setMessages((prevState) => { 53 | return [ 54 | ...prevState, 55 | newMessage 56 | ]; 57 | }); 58 | }; 59 | setConnection(connectionInit); 60 | } 61 | initConnection(); 62 | }, []) 63 | 64 | useEffect(() => { 65 | const scrollToBottom = () => { 66 | messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }) 67 | } 68 | scrollToBottom(); 69 | }); 70 | 71 | const updateUsername = username => { 72 | setUsername(username); 73 | setShowSignIn(false); 74 | chatRef.current.focus() 75 | } 76 | 77 | const handleOnClick = () => { 78 | if (!username) { 79 | setShowSignIn(true); 80 | } 81 | } 82 | 83 | const handleChange = e => { 84 | setMessage(e.target.value); 85 | } 86 | 87 | const handleKeyDown = (e) => { 88 | if (e.keyCode === 13) { // keyCode 13 is carriage return 89 | if (message) { 90 | const data = `{ 91 | "action": "sendmessage", 92 | "data": "${username}::${message.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}" 93 | }`; 94 | connection.send(data); 95 | setMessage(''); 96 | } 97 | } 98 | } 99 | 100 | const parseUrls = (userInput) => { 101 | var urlRegExp = /(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_.~#?&//=]*)/g; 102 | let formattedMessage = userInput.replace(urlRegExp, (match) => { 103 | let formattedMatch = match; 104 | if (!match.startsWith('http')) { 105 | formattedMatch = `http://${match}`; 106 | } 107 | return `${match}`; 108 | }); 109 | return formattedMessage; 110 | } 111 | 112 | const renderMessages = () => { 113 | return ( 114 | messages.map(msg => { 115 | let formattedMessage = parseUrls(msg.message); 116 | return ( 117 |
118 |

{msg.username}

119 |
120 | ) 121 | }) 122 | ) 123 | } 124 | 125 | return ( 126 | <> 127 |
128 |

Simple Live Chat demo

129 |
130 |
131 |
132 | 133 |
134 |
135 |
136 | {renderMessages()} 137 |
138 |
139 |
140 | 150 | {!username && ( 151 |
152 | 153 |
154 | )} 155 |
156 |
157 |
158 |
159 | {showSignIn && 160 | 161 | } 162 |
163 | 164 | ) 165 | } 166 | 167 | export default Chat; -------------------------------------------------------------------------------- /web-ui/src/components/chat/SignIn.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React, { useState, createRef, useEffect } from 'react'; 5 | import PropTypes from 'prop-types'; 6 | 7 | const SignIn = ({ updateUsername }) => { 8 | const [username, setUsername] = useState(''); 9 | const inputRef = createRef(); 10 | 11 | useEffect(() => { 12 | inputRef.current.focus(); 13 | }, [inputRef]); 14 | 15 | return ( 16 |
17 |
18 |

Enter your name

19 |
20 |
21 | setUsername(e.target.value)} 31 | /> 32 | 37 |
38 |
39 |
40 |
41 |
42 | ); 43 | } 44 | 45 | SignIn.propTypes = { 46 | updateUsername: PropTypes.func, 47 | }; 48 | 49 | export default SignIn; -------------------------------------------------------------------------------- /web-ui/src/components/videoPlayer/VideoPlayer.css: -------------------------------------------------------------------------------- 1 | /* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. */ 2 | /* SPDX-License-Identifier: MIT-0 */ 3 | 4 | :root { 5 | --video-width: 88.4rem; 6 | } 7 | 8 | .video-elem { 9 | top: 0; 10 | background: #000; 11 | } 12 | 13 | @media (max-width: 480px) { /* Smaller Screens */ 14 | :root { 15 | --video-width: 100%; 16 | } 17 | } 18 | 19 | @media (min-width: 480px) and (max-width: 767px) { /* Small Screens */ 20 | :root { 21 | --video-width: 100%; 22 | } 23 | } 24 | 25 | @media (min-width: 767px) and (max-width: 1024px) { /* Large Screens */ 26 | :root { 27 | --video-width: 100%; 28 | } 29 | } 30 | 31 | @media (min-width: 1024px) and (max-width: 1280px) { /* Large Screens */ 32 | :root { 33 | --video-width: 64rem; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /web-ui/src/components/videoPlayer/VideoPlayer.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React, { useEffect } from 'react'; 5 | import * as config from '../../config'; 6 | 7 | // Styles 8 | import './VideoPlayer.css'; 9 | 10 | const VideoPlayer = () => { 11 | const maxMetaData = 10; 12 | 13 | useEffect(() => { 14 | let metaData = [] 15 | const mediaPlayerScriptLoaded = () => { 16 | // This shows how to include the Amazon IVS Player with a script tag from our CDN 17 | // If self hosting, you may not be able to use the create() method since it requires 18 | // that file names do not change and are all hosted from the same directory. 19 | 20 | const MediaPlayerPackage = window.IVSPlayer; 21 | 22 | // First, check if the browser supports the Amazon IVS player. 23 | if (!MediaPlayerPackage.isPlayerSupported) { 24 | console.warn("The current browser does not support the Amazon IVS player."); 25 | return; 26 | } 27 | 28 | const PlayerState = MediaPlayerPackage.PlayerState; 29 | const PlayerEventType = MediaPlayerPackage.PlayerEventType; 30 | 31 | // Initialize player 32 | const player = MediaPlayerPackage.create(); 33 | player.attachHTMLVideoElement(document.getElementById("video-player")); 34 | 35 | // Attach event listeners 36 | player.addEventListener(PlayerState.PLAYING, () => { 37 | console.log("Player State - PLAYING"); 38 | }); 39 | player.addEventListener(PlayerState.ENDED, () => { 40 | console.log("Player State - ENDED"); 41 | }); 42 | player.addEventListener(PlayerState.READY, () => { 43 | console.log("Player State - READY"); 44 | }); 45 | player.addEventListener(PlayerEventType.ERROR, (err) => { 46 | console.warn("Player Event - ERROR:", err); 47 | }); 48 | player.addEventListener(PlayerEventType.TEXT_METADATA_CUE, (cue) => { 49 | console.log('Timed metadata: ', cue.text); 50 | const metadataText = JSON.parse(cue.text); 51 | const productId = metadataText['productId']; 52 | const metadataTime = player.getPosition().toFixed(2); 53 | 54 | // only keep max 5 metadata records 55 | if (metaData.length > maxMetaData) { 56 | metaData.length = maxMetaData; 57 | } 58 | // insert new metadata 59 | metaData.unshift(`productId: ${productId} (${metadataTime}s)`); 60 | }); 61 | 62 | // Setup stream and play 63 | player.setAutoplay(true); 64 | player.load(config.PLAYBACK_URL); 65 | player.setVolume(0.5); 66 | } 67 | const mediaPlayerScript = document.createElement("script"); 68 | mediaPlayerScript.src = "https://player.live-video.net/1.8.0/amazon-ivs-player.min.js"; 69 | mediaPlayerScript.async = true; 70 | mediaPlayerScript.onload = () => mediaPlayerScriptLoaded(); 71 | document.body.appendChild(mediaPlayerScript); 72 | }, []); 73 | 74 | return ( 75 |
76 |
77 | 78 |
79 |
80 | ) 81 | } 82 | 83 | export default VideoPlayer; 84 | -------------------------------------------------------------------------------- /web-ui/src/config.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | // Amazon IVS Playback URL 5 | // Replace this with your own Amazon IVS Playback URL 6 | export const PLAYBACK_URL = "https://fcc3ddae59ed.us-west-2.playback.live-video.net/api/video/v1/us-west-2.893648527354.channel.DmumNckWFTqz.m3u8"; 7 | 8 | // Chat websocket address 9 | export const CHAT_WEBSOCKET = ""; -------------------------------------------------------------------------------- /web-ui/src/index.css: -------------------------------------------------------------------------------- 1 | /* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. */ 2 | /* SPDX-License-Identifier: MIT-0 */ 3 | 4 | /* Reset */ 5 | *,*::before,*::after{box-sizing:border-box}ul[class],ol[class]{padding:0}body,h1,h2,h3,h4,p,ul[class],ol[class],figure,blockquote,dl,dd{margin:0}html{scroll-behavior:smooth}body{min-height:100vh;text-rendering:optimizeSpeed;line-height:1.5}ul[class],ol[class]{list-style:none}a:not([class]){text-decoration-skip-ink:auto}img{max-width:100%;display:block}article>*+*{margin-top:1em}input,button,textarea,select{font:inherit}@media (prefers-reduced-motion:reduce){*{animation-duration:0.01ms!important;animation-iteration-count:1!important;transition-duration:0.01ms!important;scroll-behavior:auto!important}} 6 | 7 | /* --------------------------------------------------------------- */ 8 | /* Variables */ 9 | :root { 10 | /* Fonts */ 11 | --font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; 12 | --font-serif: 'Iowan Old Style', 'Apple Garamond', Baskerville, 'Times New Roman', 'Droid Serif', Times, 'Source Serif Pro', serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; 13 | --font-mono: Consolas, monaco, 'Ubuntu Mono', 'Liberation Mono', 'Courier New', Courier, monospace; 14 | 15 | /* Color tokens */ 16 | --color--primary: #2B44FF; 17 | --color--secondary: #2026A2; 18 | --color--tertiary: #8D9CA7; 19 | --color--positive: #0FD70B; 20 | --color--destructive: #FD2222; 21 | 22 | /* Sizing */ 23 | --section-max-width: 800px; 24 | --input-height: 42px; 25 | --radius: 10px; 26 | --radius-small: 4px; 27 | --header-height: 50px; 28 | --btn-floating-size: 56px; 29 | --btn-floating-icon-size: 40px; 30 | --modal-min-width: 480px; 31 | 32 | /* Light theme color assignment */ 33 | --color-text-base: #000; 34 | --color-text-alt: #4B5358; 35 | --color-text-inverted: #FFF; 36 | --color-text-hint: #8D9CA7; 37 | --color-text-primary: var(--color--primary); 38 | --color-text-secondary: var(--color--secondary); 39 | --color-text-tertiary: var(--color--tertiary); 40 | --color-text-positive: var(--color--positive); 41 | --color-text-destructive: var(--color--destructive); 42 | --color-bg-body: #FFF; 43 | --color-bg-base: #FFF; 44 | --color-bg-alt: #F1F2F3; 45 | --color-bg-alt-2: #E7ECF0; 46 | --color-bg-inverted: #000; 47 | --color-bg-primary: var(--color--primary); 48 | --color-bg-secondary: var(--color--secondary); 49 | --color-bg-tertiary: var(--color--tertiary); 50 | --color-bg-positive: var(--color--positive); 51 | --color-bg-destructive: var(--color--destructive); 52 | --color-bg-header: var(--color-bg-body); 53 | --color-bg-modal: var(--color-bg-body); 54 | --color-bg-modal-overlay: var(--color--secondary); 55 | --color-bg-chat: var(--color-bg-body); 56 | --color-bg-chat-bubble: var(--color-bg-alt); 57 | --color-bg-player: var(--color-bg-alt); 58 | --color-bg-placeholder: var(--color-bg-alt); 59 | --color-bg-button: var(--color-bg-alt); 60 | --color-bg-button-active: var(); 61 | --color-bg-button-focus: var(); 62 | --color-bg-button-hover: var(); 63 | --color-bg-button-inverted: var(); 64 | --color-bg-button-inverted-active: var(); 65 | --color-bg-button-inverted-focus: var(); 66 | --color-bg-button-inverted-hover: var(); 67 | --color-bg-button-primary-default: var(--color--primary); 68 | --color-bg-button-primary-active: var(); 69 | --color-bg-button-primary-hover: var(); 70 | --color-bg-button-secondary-default: var(--color-bg-alt); 71 | --color-bg-button-secondary-active: var(); 72 | --color-bg-button-secondary-hover: var(); 73 | --color-bg-button-floating: var(--color--primary); 74 | --color-bg-button-floating-active: var(); 75 | --color-bg-button-floating-focus: var(); 76 | --color-bg-button-floating-hover: var(--color--secondary); 77 | --color-bg-input: var(--color-bg-alt); 78 | --color-bg-input-focus: var(); 79 | --color-bg-notice-success: var(--color--positive); 80 | --color-bg-notice-error: var(--color--destructive); 81 | --color-border-base: #DFE5E9; 82 | --color-border-error: var(--color--destructive); 83 | 84 | --grid-2-columns: 1fr 1fr; 85 | --grid-3-columns: 1fr 1fr 1fr; 86 | --grid-4-columns: 1fr 1fr 1fr 1fr; 87 | --grid-trio-columns: 1fr 3fr 1fr 1fr; 88 | } 89 | 90 | @media (max-width: 480px) { /* Smaller Screens */ 91 | :root { 92 | --section-max-width: 800px; 93 | --input-height: 42px; 94 | --radius: 10px; 95 | --radius-small: 4px; 96 | --header-height: 50px; 97 | --btn-floating-size: 56px; 98 | --btn-floating-icon-size: 40px; 99 | --modal-min-width: 320px; 100 | 101 | --grid-2-columns: 1fr; 102 | --grid-3-columns: 1fr; 103 | --grid-4-columns: 1fr; 104 | --grid-trio-columns: 1fr 1fr 1fr 1fr; 105 | } 106 | } 107 | 108 | @media (min-width: 480px) and (max-width: 767px) { /* Small Screens */ 109 | :root { 110 | --section-max-width: 800px; 111 | --input-height: 42px; 112 | --radius: 10px; 113 | --radius-small: 4px; 114 | --header-height: 50px; 115 | --btn-floating-size: 56px; 116 | --btn-floating-icon-size: 40px; 117 | 118 | --grid-2-columns: 1fr 1fr; 119 | --grid-3-columns: 1fr 1fr 1fr; 120 | --grid-4-columns: 1fr 1fr; 121 | --grid-trio-columns: 1fr 2fr 1fr 1fr; 122 | } 123 | } 124 | 125 | /* --------------------------------------------------------------- */ 126 | 127 | /* Style */ 128 | html { 129 | font-size: 62.5%; 130 | } 131 | 132 | html, 133 | body { 134 | width: 100%; 135 | height: 100%; 136 | margin: 0; 137 | padding: 0; 138 | color: var(--color-text-base); 139 | background: var(--color-bg-base); 140 | line-height: 1.5; 141 | } 142 | 143 | body { 144 | font-family: var(--font-sans); 145 | font-size: 1.6rem; 146 | } 147 | 148 | ::selection { 149 | background: var(--color--primary); 150 | color: var(--color-text-inverted); 151 | } 152 | 153 | a { 154 | text-decoration: none; 155 | } 156 | 157 | /* Section */ 158 | section { 159 | max-width: var(--section-max-width); 160 | margin: 0 auto; 161 | } 162 | 163 | h1 { 164 | font-size: 3.6rem; 165 | } 166 | 167 | h2 { 168 | font-size: 2.4rem; 169 | } 170 | 171 | h3 { 172 | font-size: 1.8rem; 173 | font-weight: 300; 174 | } 175 | 176 | ul { 177 | margin: 0; 178 | padding: 1rem 0; 179 | list-style-position: inside; 180 | } 181 | 182 | ul li { 183 | margin: 0; 184 | } 185 | 186 | em { 187 | font-weight: 300; 188 | font-size: 1.4rem; 189 | } 190 | 191 | .formatted-text h1 { margin-bottom: 1rem; } 192 | .formatted-text h2 { margin-bottom: 1rem; } 193 | .formatted-text h3 { margin-bottom: 0.5rem; } 194 | .formatted-text ul { margin-bottom: 0.5rem; } 195 | .formatted-text p { margin-bottom: 0.5rem; } 196 | .formatted-text p:last-child { margin-bottom: 0; } 197 | 198 | /* Utility - Text */ 199 | .color-base { color: var(--color-text-base); } 200 | .color-alt { color: var(--color-text-alt); } 201 | .color-inverted { color: var(--color-text-inverted); } 202 | .color-hint { color: var(--color-text-hint); } 203 | .color-primary { color: var(--color-text-primary); } 204 | .color-secondary { color: var(--color-text-secondary); } 205 | .color-tertiary { color: var(--color-text-tertiary); } 206 | .color-positive { color: var(--color-text-positive); } 207 | .color-destructive { color: var(--color-text-destructive); } 208 | 209 | /* Utility - Background */ 210 | .bg-body { background-color: var(--color-bg-body); } 211 | .bg-base { background-color: var(--color-bg-base); } 212 | .bg-alt { background-color: var(--color-bg-alt); } 213 | .bg-alt-2 { background-color: var(--color-bg-alt-2); } 214 | .bg-inverted { background-color: var(--color-bg-inverted); } 215 | .bg-primary { background-color: var(--color-bg-primary); } 216 | .bg-secondary { background-color: var(--color-bg-secondary); } 217 | .bg-tertiary { background-color: var(--color-bg-tertiary); } 218 | .bg-positive { background-color: var(--color-bg-positive); } 219 | .bg-destructive { background-color: var(--color-bg-destructive); } 220 | 221 | /* Utility - Radius */ 222 | .br-all { border-radius: var(--radius); } 223 | 224 | /* Utility - Padding */ 225 | .pd-0 {padding: 0;} 226 | .pd-05 {padding: 0.5rem;} 227 | .pd-1 {padding: 1rem;} 228 | .pd-15 {padding: 1.5rem;} 229 | .pd-2 {padding: 2rem;} 230 | .pd-25 {padding: 2.5rem;} 231 | .pd-3 {padding: 3rem;} 232 | .pd-35 {padding: 3.5rem;} 233 | .pd-4 {padding: 4rem;} 234 | .pd-5 {padding: 5rem;} 235 | 236 | .pd-x-0 {padding-left: 0; padding-right: 0} 237 | .pd-x-05 {padding-left: 0.5rem; padding-right: 0.5rem} 238 | .pd-x-1 {padding-left: 1rem; padding-right: 1rem;} 239 | .pd-x-15 {padding-left: 1.5rem; padding-right: 1.5rem;} 240 | .pd-x-2 {padding-left: 2rem; padding-right: 2rem;} 241 | .pd-x-25 {padding-left: 2.5rem; padding-right: 2.5rem;} 242 | .pd-x-3 {padding-left: 3rem; padding-right: 3rem;} 243 | .pd-x-35 {padding-left: 3.5rem; padding-right: 3rem;} 244 | .pd-x-4 {padding-left: 4rem; padding-right: 4rem;} 245 | .pd-x-5 {padding-left: 5rem; padding-right: 5rem;} 246 | 247 | .pd-y-0 {padding-top: 0; padding-bottom: 0} 248 | .pd-y-05 {padding-top: 0.5rem; padding-bottom: 0.5rem} 249 | .pd-y-1 {padding-top: 1rem; padding-bottom: 1rem;} 250 | .pd-y-15 {padding-top: 1.5rem; padding-bottom: 1.5rem;} 251 | .pd-y-2 {padding-top: 2rem; padding-bottom: 2rem;} 252 | .pd-y-25 {padding-top: 2.5rem; padding-bottom: 2.5rem;} 253 | .pd-y-3 {padding-top: 3rem; padding-bottom: 3rem;} 254 | .pd-y-35 {padding-top: 3.5rem; padding-bottom: 3rem;} 255 | .pd-y-4 {padding-top: 4rem; padding-bottom: 4rem;} 256 | .pd-y-5 {padding-top: 5rem; padding-bottom: 5rem;} 257 | 258 | .pd-t-0 {padding-top: 0;} 259 | .pd-t-05 {padding-top: 0.5rem;} 260 | .pd-t-1 {padding-top: 1rem;} 261 | .pd-t-15 {padding-top: 1.5rem;} 262 | .pd-t-2 {padding-top: 2rem;} 263 | .pd-t-25 {padding-top: 2.5rem;} 264 | .pd-t-3 {padding-top: 3rem;} 265 | .pd-t-35 {padding-top: 3.5rem;} 266 | .pd-t-4 {padding-top: 4rem;} 267 | .pd-t-5 {padding-top: 5rem;} 268 | 269 | .pd-r-0 {padding-right: 0;} 270 | .pd-r-05 {padding-right: 0.5rem;} 271 | .pd-r-1 {padding-right: 1rem;} 272 | .pd-r-15 {padding-right: 1.5rem;} 273 | .pd-r-2 {padding-right: 2rem;} 274 | .pd-r-25 {padding-right: 2.5rem;} 275 | .pd-r-3 {padding-right: 3rem;} 276 | .pd-r-35 {padding-right: 3.5rem;} 277 | .pd-r-4 {padding-right: 4rem;} 278 | .pd-r-5 {padding-right: 5rem;} 279 | 280 | .pd-b-0 {padding-bottom: 0;} 281 | .pd-b-05 {padding-bottom: 0.5rem;} 282 | .pd-b-1 {padding-bottom: 1rem;} 283 | .pd-b-15 {padding-bottom: 1.5rem;} 284 | .pd-b-2 {padding-bottom: 2rem;} 285 | .pd-b-25 {padding-bottom: 2.5rem;} 286 | .pd-b-3 {padding-bottom: 3rem;} 287 | .pd-b-35 {padding-bottom: 3.5rem;} 288 | .pd-b-4 {padding-bottom: 4rem;} 289 | .pd-b-5 {padding-bottom: 5rem;} 290 | 291 | .pd-l-0 {padding-left: 0;} 292 | .pd-l-05 {padding-left: 0.5rem;} 293 | .pd-l-1 {padding-left: 1rem;} 294 | .pd-l-15 {padding-left: 1.5rem;} 295 | .pd-l-2 {padding-left: 2rem;} 296 | .pd-l-25 {padding-left: 2.5rem;} 297 | .pd-l-3 {padding-left: 3rem;} 298 | .pd-l-35 {padding-left: 3.5rem;} 299 | .pd-l-4 {padding-left: 4rem;} 300 | .pd-l-5 {padding-left: 5rem;} 301 | 302 | /* Utility - Margin */ 303 | .mg-0 {margin: 0;} 304 | .mg-05 {margin: 0.5rem;} 305 | .mg-1 {margin: 1rem;} 306 | .mg-15 {margin: 1.5rem;} 307 | .mg-2 {margin: 2rem;} 308 | .mg-25 {margin: 2.5rem;} 309 | .mg-3 {margin: 3rem;} 310 | .mg-35 {margin: 3.5rem;} 311 | .mg-4 {margin: 4rem;} 312 | .mg-5 {margin: 5rem;} 313 | 314 | .mg-x-0 {margin-left: 0; margin-right: 0} 315 | .mg-x-05 {margin-left: 0.5rem; margin-right: 0.5rem} 316 | .mg-x-1 {margin-left: 1rem; margin-right: 1rem;} 317 | .mg-x-15 {margin-left: 1.5rem; margin-right: 1.5rem;} 318 | .mg-x-2 {margin-left: 2rem; margin-right: 2rem;} 319 | .mg-x-25 {margin-left: 2.5rem; margin-right: 2.5rem;} 320 | .mg-x-3 {margin-left: 3rem; margin-right: 3rem;} 321 | .mg-x-35 {margin-left: 3.5rem; margin-right: 3rem;} 322 | .mg-x-4 {margin-left: 4rem; margin-right: 4rem;} 323 | .mg-x-5 {margin-left: 5rem; margin-right: 5rem;} 324 | 325 | .mg-y-0 {margin-top: 0; margin-bottom: 0} 326 | .mg-y-05 {margin-top: 0.5rem; margin-bottom: 0.5rem} 327 | .mg-y-1 {margin-top: 1rem; margin-bottom: 1rem;} 328 | .mg-y-15 {margin-top: 1.5rem; margin-bottom: 1.5rem;} 329 | .mg-y-2 {margin-top: 2rem; margin-bottom: 2rem;} 330 | .mg-y-25 {margin-top: 2.5rem; margin-bottom: 2.5rem;} 331 | .mg-y-3 {margin-top: 3rem; margin-bottom: 3rem;} 332 | .mg-y-35 {margin-top: 3.5rem; margin-bottom: 3rem;} 333 | .mg-y-4 {margin-top: 4rem; margin-bottom: 4rem;} 334 | .mg-y-5 {margin-top: 5rem; margin-bottom: 5rem;} 335 | 336 | .mg-t-0 {margin-top: 0;} 337 | .mg-t-05 {margin-top: 0.5rem;} 338 | .mg-t-1 {margin-top: 1rem;} 339 | .mg-t-15 {margin-top: 1.5rem;} 340 | .mg-t-2 {margin-top: 2rem;} 341 | .mg-t-25 {margin-top: 2.5rem;} 342 | .mg-t-3 {margin-top: 3rem;} 343 | .mg-t-35 {margin-top: 3.5rem;} 344 | .mg-t-4 {margin-top: 4rem;} 345 | .mg-t-5 {margin-top: 5rem;} 346 | 347 | .mg-r-0 {margin-right: 0;} 348 | .mg-r-05 {margin-right: 0.5rem;} 349 | .mg-r-1 {margin-right: 1rem;} 350 | .mg-r-15 {margin-right: 1.5rem;} 351 | .mg-r-2 {margin-right: 2rem;} 352 | .mg-r-25 {margin-right: 2.5rem;} 353 | .mg-r-3 {margin-right: 3rem;} 354 | .mg-r-35 {margin-right: 3.5rem;} 355 | .mg-r-4 {margin-right: 4rem;} 356 | .mg-r-5 {margin-right: 5rem;} 357 | 358 | .mg-b-0 {margin-bottom: 0;} 359 | .mg-b-05 {margin-bottom: 0.5rem;} 360 | .mg-b-1 {margin-bottom: 1rem;} 361 | .mg-b-15 {margin-bottom: 1.5rem;} 362 | .mg-b-2 {margin-bottom: 2rem;} 363 | .mg-b-25 {margin-bottom: 2.5rem;} 364 | .mg-b-3 {margin-bottom: 3rem;} 365 | .mg-b-35 {margin-bottom: 3.5rem;} 366 | .mg-b-4 {margin-bottom: 4rem;} 367 | .mg-b-5 {margin-bottom: 5rem;} 368 | 369 | .mg-l-0 {margin-left: 0;} 370 | .mg-l-05 {margin-left: 0.5rem;} 371 | .mg-l-1 {margin-left: 1rem;} 372 | .mg-l-15 {margin-left: 1.5rem;} 373 | .mg-l-2 {margin-left: 2rem;} 374 | .mg-l-25 {margin-left: 2.5rem;} 375 | .mg-l-3 {margin-left: 3rem;} 376 | .mg-l-35 {margin-left: 3.5rem;} 377 | .mg-l-4 {margin-left: 4rem;} 378 | .mg-l-5 {margin-left: 5rem;} 379 | 380 | /* Utility - Flex */ 381 | .fl { display: flex; } 382 | .fl-inline { display: inline-flex; } 383 | 384 | .fl-row { flex-direction: row; } /* Default */ 385 | .fl-row-rev { flex-direction: row-reverse; } 386 | .fl-col { flex-direction: column; } 387 | .fl-col-rev { flex-direction: column-reverse; } 388 | 389 | .fl-nowrap { flex-wrap: nowrap; } /* Default */ 390 | .fl-wrap { flex-wrap: wrap; } 391 | .fl-wrap-rev { flex-wrap: wrap-reverse; } 392 | 393 | .fl-j-start { justify-content: flex-start; } /* Default */ 394 | .fl-j-end { justify-content: flex-end; } 395 | .fl-j-center { justify-content: center; } 396 | .fl-j-around { justify-content: space-around; } 397 | .fl-j-between { justify-content: space-between; } 398 | 399 | .fl-a-stretch { align-items: stretch; } /* Default */ 400 | .fl-a-start { align-items: flex-start; } 401 | .fl-a-center { align-items: center; } 402 | .fl-a-end { align-items: flex-end; } 403 | .fl-a-baseline { align-items: baseline; } 404 | 405 | .fl-grow-0 { flex-grow: 0; } /* Default */ 406 | .fl-grow-1 { flex-grow: 1; } 407 | 408 | .fl-shrink-1 { flex-shrink: 1; } /* Default */ 409 | .fl-shrink-0 { flex-shrink: 0; } 410 | 411 | .fl-b-auto { flex-basis: auto; } /* Default */ 412 | .fl-b-0 { flex-basis: 0; } 413 | 414 | .fl-a-auto { align-self: auto; } /* Default */ 415 | .fl-a-start { align-self: flex-start; } 416 | .fl-a-center { align-self: center; } 417 | .fl-a-end { align-self: flex-end; } 418 | .fl-a-stretch { align-self: stretch; } 419 | .fl-a-baseline { align-self: baseline } 420 | 421 | /* Utility - Position */ 422 | .pos-absolute { position: absolute !important; } 423 | .pos-fixed { position: fixed !important; } 424 | .pos-relative { position: relative !important; } 425 | .top-0 { top: 0 !important; } 426 | .bottom-0 { bottom: 0 !important; } 427 | 428 | /* Utility - Width/Height */ 429 | .full-width { width: 100%; } 430 | .full-height { height: 100%; } 431 | 432 | /* Blur */ 433 | .blur { 434 | filter: blur(70px); 435 | } 436 | 437 | /* Overflow */ 438 | .no-overflow { 439 | overflow: hidden; 440 | } 441 | 442 | /* Grid */ 443 | .grid { 444 | width: 100%; 445 | display: grid; 446 | grid-gap: 1rem; 447 | } 448 | 449 | .grid.grid--2 { 450 | grid-template-columns: 1fr 1fr; 451 | } 452 | 453 | .grid.grid--3 { 454 | grid-template-columns: 1fr 1fr 1fr; 455 | } 456 | 457 | .grid.grid--4 { 458 | grid-template-columns: 1fr 1fr 1fr 1fr; 459 | } 460 | 461 | .grid.grid--trio { 462 | grid-template-columns: 1fr 3fr 1fr 1fr; 463 | } 464 | 465 | /* Responsive Grid */ 466 | .grid--responsive.grid--2 { 467 | grid-template-columns: var(--grid-2-columns); 468 | } 469 | 470 | .grid--responsive.grid--3 { 471 | grid-template-columns: var(--grid-3-columns); 472 | } 473 | 474 | .grid--responsive.grid--4 { 475 | grid-template-columns: var(--grid-4-columns); 476 | } 477 | 478 | .grid--responsive.grid--trio { 479 | grid-template-columns: var(--grid-trio-columns); 480 | } 481 | 482 | /* Header bar */ 483 | header { 484 | height: var(--header-height); 485 | box-shadow: 0 1px 0 0 var(--color-border-base); 486 | position: sticky; 487 | top: 0; 488 | left: 0; 489 | right: 0; 490 | padding: 0 2rem; 491 | background: var(--color-bg-header); 492 | z-index: 10; 493 | } 494 | 495 | header h1 { 496 | font-size: 16px; 497 | font-weight: 800; 498 | line-height: var(--header-height); 499 | } 500 | 501 | /* Modal */ 502 | .modal { 503 | width: 100%; 504 | height: 100vh; 505 | position: absolute; 506 | top: 0; 507 | left: 0; 508 | display: grid; 509 | place-items: center; 510 | z-index: 2; 511 | } 512 | 513 | .modal__el { 514 | max-width: 570px; 515 | min-width: var(--modal-min-width); 516 | position: relative; 517 | z-index: 2; 518 | background: var(--color-bg-modal); 519 | display: flex; 520 | flex-direction: column; 521 | padding: 3rem; 522 | } 523 | 524 | .modal__overlay { 525 | position: absolute; 526 | top: 0; 527 | right: 0; 528 | bottom: 0; 529 | left: 0; 530 | background: var(--color-bg-modal-overlay); 531 | opacity: .9; 532 | } 533 | 534 | /* Code */ 535 | code, 536 | pre, 537 | kbd, 538 | samp { 539 | font-family: var(--font-mono); 540 | } 541 | 542 | .codeblock { 543 | padding: 1rem; 544 | color: var(--color-text-alt); 545 | background: var(--color-bg-placeholder); 546 | border-radius: var(--radius); 547 | } 548 | 549 | /* Placeholder blocks */ 550 | .placeholder { 551 | min-height: 180px; 552 | background: var(--color-bg-placeholder); 553 | border-radius: var(--radius); 554 | } 555 | 556 | /* Aspect ratio */ 557 | .aspect-169 { 558 | padding-top: 56.25%; 559 | height: 0; 560 | overflow: hidden; 561 | } 562 | 563 | .player { 564 | background: var(--color-bg-player); 565 | } 566 | 567 | /* Buttons & Forms */ 568 | form { 569 | display: flex; 570 | flex-direction: column; 571 | align-items: flex-start; 572 | } 573 | 574 | fieldset { 575 | width: 100%; 576 | border: 0; 577 | padding: 0; 578 | margin: 0; 579 | display: flex; 580 | flex-direction: column; 581 | } 582 | 583 | fieldset input, 584 | fieldset textarea, 585 | fieldset select, 586 | fieldset button { 587 | width: 100%; 588 | margin-bottom: 1rem; 589 | } 590 | 591 | label { 592 | font-weight: 500; 593 | } 594 | 595 | label span { 596 | font-weight: 200; 597 | } 598 | 599 | button { 600 | border: 2px solid transparent; 601 | outline: none; 602 | appearance: none; 603 | cursor: pointer; 604 | -webkit-appearance: none; 605 | border-radius: var(--radius-small); 606 | } 607 | 608 | input, 609 | select, 610 | textarea { 611 | border: 2px solid transparent; 612 | outline: none; 613 | appearance: none; 614 | resize: none; 615 | -webkit-appearance: none; 616 | padding: 1rem; 617 | background: var(--color-bg-input); 618 | border-radius: var(--radius-small); 619 | } 620 | 621 | .btn, 622 | button, 623 | select, 624 | input[type="text"], 625 | input[type="password"], 626 | input[type="submit"], 627 | input[type="reset"], 628 | input[type="button"] { 629 | height: var(--input-height); 630 | } 631 | 632 | input:focus, 633 | textarea:focus, 634 | .btn:focus, 635 | .btn:active { 636 | border: 2px solid var(--color--primary); 637 | } 638 | 639 | select { 640 | padding: 0 20px 0 10px; 641 | position: relative; 642 | } 643 | 644 | select:focus, 645 | select:active { 646 | border: 2px solid var(--color--primary); 647 | } 648 | 649 | .btn.rounded, 650 | input.rounded { 651 | border-radius: var(--input-height); 652 | } 653 | 654 | .btn { 655 | font-weight: 500; 656 | background: var(--color-bg-button); 657 | } 658 | 659 | .btn--primary { 660 | background: var(--color-bg-button-primary-default); 661 | color: var(--color-text-inverted); 662 | } 663 | 664 | .btn--primary:hover, 665 | .btn--primary:focus { 666 | background: var(--color--secondary); 667 | } 668 | 669 | .btn--secondary { 670 | background: var(--color-bg-button-secondary-default); 671 | color: var(--color-text-base); 672 | } 673 | 674 | .btn--destruct { 675 | background: var(--color--destructive); 676 | color: var(--color-text-inverted); 677 | } 678 | 679 | .btn--confirm { 680 | background: var(--color--positive); 681 | } 682 | 683 | .btn--floating { 684 | width: var(--btn-floating-size); 685 | height: var(--btn-floating-size); 686 | background: var(--color-bg-button-floating); 687 | border-radius: var(--btn-floating-size); 688 | color: var(--color-text-inverted); 689 | display: flex; 690 | align-items: center; 691 | position: absolute; 692 | bottom: 2rem; 693 | right: 2rem; 694 | } 695 | 696 | .btn--floating svg { 697 | width: var(--btn-floating-icon-size); 698 | height: var(--btn-floating-icon-size); 699 | fill: var(--color-text-inverted); 700 | } 701 | 702 | .btn--floating:hover, 703 | .btn--floating:focus { 704 | background: var(--color-bg-button-floating-hover); 705 | } 706 | 707 | .btn--fixed { 708 | position: fixed; 709 | } 710 | 711 | /* Interactive */ 712 | .interactive { 713 | cursor: pointer; 714 | border: 2px solid transparent; 715 | display: flex; 716 | padding: 1rem; 717 | flex-direction: column; 718 | color: var(--color-text-base); 719 | overflow: hidden; 720 | } 721 | 722 | .interactive strong, 723 | .interactive span { 724 | text-overflow: ellipsis; 725 | white-space: nowrap; 726 | overflow: hidden; 727 | } 728 | 729 | .interactive:focus, 730 | .interactive:hover { 731 | background: var(--color-bg-button); 732 | color: var(--color-bg-button-primary-default); 733 | } 734 | 735 | .interactive:focus { 736 | border: 2px solid var(--color--primary); 737 | outline: none; 738 | } 739 | 740 | .interactive--active, 741 | .interactive--active:hover, 742 | .interactive--active:focus { 743 | background: var(--color-bg-button-primary-default); 744 | color: var(--color-text-inverted); 745 | } 746 | 747 | /* Notices */ 748 | .notice { 749 | border-radius: var(--radius-small); 750 | position: absolute; 751 | top: 1.5rem; 752 | right: 1.5rem; 753 | } 754 | 755 | .notice__content { 756 | display: flex; 757 | padding: 1.5rem 2rem; 758 | font-weight: 600; 759 | } 760 | 761 | .notice--success { 762 | background: var(--color-bg-notice-success); 763 | } 764 | 765 | .notice--error { 766 | background: var(--color-bg-notice-error); 767 | color: var(--color-text-inverted); 768 | } 769 | 770 | .notice__icon { 771 | margin-right: 1rem; 772 | } 773 | 774 | 775 | /* Chat */ 776 | .chat-wrapper { 777 | position: relative; 778 | padding-bottom: calc(var(--input-height) + 30px); 779 | display: flex; 780 | flex-direction: column; 781 | align-items: flex-start; 782 | } 783 | 784 | .chat-line { 785 | padding: 12px 15px; 786 | background: var(--color-bg-chat-bubble); 787 | border-radius: var(--input-height); 788 | display: flex; 789 | margin: 0 0 5px 0; 790 | } 791 | 792 | .chat-line p { 793 | display: inline; 794 | font-weight: normal; 795 | } 796 | 797 | .chat-line .username { 798 | font-weight: 800; 799 | padding-right: .1rem; 800 | } 801 | 802 | .chat-line .username::after { 803 | content: "\00a0 "; 804 | } 805 | 806 | .composer { 807 | position: absolute; 808 | bottom: 0; 809 | left: 0; 810 | right: 0; 811 | padding: 15px 0; 812 | background: var(--color-bg-chat); 813 | } 814 | 815 | .composer input { 816 | width: 100%; 817 | } 818 | 819 | /* Icons */ 820 | .icon { 821 | fill: var(--color-text-base); 822 | } 823 | 824 | .icon--inverted { 825 | fill: var(--color-text-inverted); 826 | } 827 | 828 | .icon--success { 829 | fill: var(--color--positive); 830 | } 831 | 832 | .icon--error { 833 | fill: var(--color--destructive); 834 | } 835 | 836 | .icon--14 { 837 | width: 14px; 838 | height: 14px; 839 | } 840 | 841 | .icon--24 { 842 | width: 24px; 843 | height: 24px; 844 | } 845 | 846 | .icon--36 { 847 | width: 36px; 848 | height: 36px; 849 | } 850 | 851 | .icon--48 { 852 | width: 48px; 853 | height: 48px; 854 | } 855 | -------------------------------------------------------------------------------- /web-ui/src/index.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React from 'react'; 5 | import ReactDOM from 'react-dom'; 6 | import './index.css'; 7 | import App from './components/App'; 8 | import * as serviceWorker from './serviceWorker'; 9 | 10 | ReactDOM.render( 11 | 12 | 13 | , 14 | document.getElementById('root') 15 | ); 16 | 17 | // If you want your app to work offline and load faster, you can change 18 | // unregister() to register() below. Note this comes with some pitfalls. 19 | // Learn more about service workers: https://github.com/facebook/create-react-app/blob/master/packages/cra-template/template/README.md 20 | serviceWorker.unregister(); 21 | -------------------------------------------------------------------------------- /web-ui/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { 'Service-Worker': 'script' }, 105 | }) 106 | .then(response => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get('content-type'); 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf('javascript') === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then(registration => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | 'No internet connection found. App is running in offline mode.' 127 | ); 128 | }); 129 | } 130 | 131 | export function unregister() { 132 | if ('serviceWorker' in navigator) { 133 | navigator.serviceWorker.ready 134 | .then(registration => { 135 | registration.unregister(); 136 | }) 137 | .catch(error => { 138 | console.error(error.message); 139 | }); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /web-ui/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | --------------------------------------------------------------------------------