├── web-ui ├── src │ ├── components │ │ ├── App.css │ │ ├── App.test.js │ │ ├── App.js │ │ ├── chat │ │ │ ├── RaiseHand.jsx │ │ │ ├── StickerPicker.jsx │ │ │ ├── Avatars.jsx │ │ │ ├── SignIn.jsx │ │ │ ├── Chat.css │ │ │ └── Chat.jsx │ │ └── videoPlayer │ │ │ ├── VideoPlayer.css │ │ │ └── VideoPlayer.jsx │ ├── setupTests.js │ ├── helpers │ │ └── index.js │ ├── config.js │ ├── index.js │ ├── constants.js │ ├── serviceWorker.js │ └── index.css ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── manifest.json │ └── index.html ├── .gitignore ├── package.json └── README.md ├── app-screenshot.png ├── serverless ├── .gitignore ├── dependencies │ └── nodejs │ │ └── package.json ├── app-diagram.png ├── src │ ├── chat-list.js │ ├── chat-event.js │ └── chat-auth.js ├── template.yaml └── README.md ├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── CONTRIBUTING.md ├── .github └── workflows │ └── update-ivs-sdks.yml └── THIRD-PARTY-LICENSES.txt /web-ui/src/components/App.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web-ui/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /app-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-chat-web-demo/HEAD/app-screenshot.png -------------------------------------------------------------------------------- /serverless/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | log.txt 4 | output.yaml 5 | out.yaml 6 | *.tgz -------------------------------------------------------------------------------- /serverless/dependencies/nodejs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "aws-sdk": "^2.1122.0" 4 | } 5 | } -------------------------------------------------------------------------------- /web-ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-chat-web-demo/HEAD/web-ui/public/favicon.ico -------------------------------------------------------------------------------- /serverless/app-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/amazon-ivs-chat-web-demo/HEAD/serverless/app-diagram.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 11 | .eslintcache 12 | package-lock.json -------------------------------------------------------------------------------- /.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 | .vscode 14 | 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/helpers/index.js: -------------------------------------------------------------------------------- 1 | // Creates a UUID 2 | const uuidv4 = () => { 3 | // eslint-disable-next-line 4 | return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace( 5 | /[xy]/g, 6 | function (c) { 7 | // eslint-disable-next-line 8 | var r = (Math.random() * 16) | 0, 9 | v = c === "x" ? r : (r & 0x3) | 0x8; 10 | return v.toString(16); 11 | } 12 | ); 13 | }; 14 | 15 | export { uuidv4 }; -------------------------------------------------------------------------------- /web-ui/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Amazon IVS Chat Web Demo", 3 | "name": "A demo web application intended as an educational tool for demonstrating how you can build a webapp with live video and chat using 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/src/config.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | /* eslint-disable */ 5 | 6 | // Amazon IVS Playback URL 7 | // Replace this with your own Amazon IVS Playback URL 8 | export const PLAYBACK_URL = "https://760b256a3da8.us-east-1.playback.live-video.net/api/video/v1/us-east-1.049054135175.channel.6tM2Z9kY16nH.m3u8"; 9 | 10 | // Chat websocket address 11 | // The AWS region that your room is created in. For example, `us-west-2`. 12 | export const CHAT_REGION = ""; 13 | 14 | // Chat API URL 15 | // The Amazon IVS Chat backend endpoint. You must deploy the serverless backend to get this value. 16 | export const API_URL = ""; 17 | 18 | // Chat room id (ARN) 19 | export const CHAT_ROOM_ID = ""; -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /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 | "amazon-ivs-chat-messaging": "^1.1.1", 10 | "axios": "^0.26.0", 11 | "linkify-react": "^4.0.2", 12 | "linkifyjs": "^4.0.2", 13 | "node-forge": ">=0.10.0", 14 | "react": "^17.0.2", 15 | "react-dom": "^17.0.2", 16 | "react-scripts": "5.0.0" 17 | }, 18 | "scripts": { 19 | "start": "react-scripts start", 20 | "build": "react-scripts build", 21 | "test": "react-scripts test" 22 | }, 23 | "eslintConfig": { 24 | "extends": "react-app" 25 | }, 26 | "browserslist": { 27 | "production": [ 28 | ">0.2%", 29 | "not dead", 30 | "not op_mini all" 31 | ], 32 | "development": [ 33 | "last 1 chrome version", 34 | "last 1 firefox version", 35 | "last 1 safari version" 36 | ] 37 | }, 38 | "homepage": "/" 39 | } 40 | -------------------------------------------------------------------------------- /serverless/src/chat-list.js: -------------------------------------------------------------------------------- 1 | const AWS = require("aws-sdk"); 2 | const IVSChat = new AWS.Ivschat(); 3 | 4 | const response = { 5 | statusCode: 200, 6 | headers: { 7 | "Access-Control-Allow-Headers" : "Content-Type", 8 | "Access-Control-Allow-Origin": "*", 9 | "Access-Control-Allow-Methods": "POST" 10 | }, 11 | body: "" 12 | }; 13 | 14 | /** 15 | * A function that lists all IVSChat rooms in the current account and region. 16 | */ 17 | exports.chatListHandler = async (event) => { 18 | if (event.httpMethod !== 'GET') { 19 | throw new Error(`chatListHandler only accepts GET method, you tried: ${event.httpMethod}`); 20 | } 21 | 22 | console.info('chatListHandler received:', event); 23 | 24 | try { 25 | const data = await IVSChat.listRooms().promise(); 26 | console.info("chatListHandler > IVSChat.listRooms > Success"); 27 | response.statusCode = 200; 28 | response.body = JSON.stringify(data); 29 | } catch (err) { 30 | console.error('ERROR: chatListHandler > IVSChat.listRooms:', err); 31 | response.statusCode = 500; 32 | response.body = err.stack; 33 | } 34 | 35 | console.info(`response from: ${event.path} statusCode: ${response.statusCode} body: ${response.body}`); 36 | return response; 37 | } 38 | -------------------------------------------------------------------------------- /web-ui/src/components/chat/RaiseHand.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const RaiseHand = ({ isRaised, handleRaiseHandSend }) => { 5 | const handleButtonClick = (e) => { 6 | e.preventDefault(); 7 | handleRaiseHandSend(); 8 | }; 9 | return ( 10 | <> 11 | 21 | 22 | ); 23 | }; 24 | 25 | RaiseHand.props = { 26 | handleRaiseHandSend: PropTypes.func, 27 | }; 28 | 29 | export default RaiseHand; 30 | -------------------------------------------------------------------------------- /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 | --raise-hand-margin: 1.2rem; 7 | } 8 | 9 | .video-elem { 10 | top: 0; 11 | background: #000; 12 | } 13 | 14 | .overlay-raise-hand { 15 | color: var(--color-text-inverted); 16 | padding-top: 0.6rem; 17 | padding-bottom: 0.6rem; 18 | padding-left: 1.2rem; 19 | padding-right: 1.2rem; 20 | background: var(--color-bg-secondary); 21 | border-radius: var(--radius); 22 | position: absolute; 23 | display: flex; 24 | justify-content: center; 25 | align-items: center; 26 | bottom: var(--raise-hand-margin); 27 | } 28 | 29 | .overlay-raise-hand-icon { 30 | width: 2.4rem; 31 | height: 2.4rem; 32 | fill: currentColor; 33 | display: flex; 34 | align-items: center; 35 | padding: 0.4rem; 36 | } 37 | 38 | .overlay-raise-hand-icon > svg { 39 | width: 100%; 40 | height: 100%; 41 | display: inline-flex; 42 | } 43 | 44 | .player-overlay { 45 | top: 0; 46 | left: 0; 47 | right: 0; 48 | bottom: 0; 49 | display: flex; 50 | align-items: center; 51 | justify-content: center; 52 | } 53 | 54 | @media (max-width: 480px) { 55 | /* Smaller Screens */ 56 | :root { 57 | --video-width: 100%; 58 | } 59 | } 60 | 61 | @media (min-width: 480px) and (max-width: 767px) { 62 | /* Small Screens */ 63 | :root { 64 | --video-width: 100%; 65 | } 66 | } 67 | 68 | @media (min-width: 767px) and (max-width: 1024px) { 69 | /* Large Screens */ 70 | :root { 71 | --video-width: 100%; 72 | } 73 | } 74 | 75 | @media (min-width: 1024px) and (max-width: 1280px) { 76 | /* Large Screens */ 77 | :root { 78 | --video-width: 64rem; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /serverless/src/chat-event.js: -------------------------------------------------------------------------------- 1 | const AWS = require("aws-sdk"); 2 | const IVSChat = new AWS.Ivschat(); 3 | 4 | const response = { 5 | statusCode: 200, 6 | headers: { 7 | "Access-Control-Allow-Headers" : "Content-Type", 8 | "Access-Control-Allow-Origin": "*", 9 | "Access-Control-Allow-Methods": "POST" 10 | }, 11 | body: "" 12 | }; 13 | 14 | /** 15 | * A function that sends an event to a specified IVS chat room. 16 | */ 17 | exports.chatEventHandler = async (event) => { 18 | if (event.httpMethod !== 'POST') { 19 | throw new Error(`chatEventHandler only accepts POST method, you tried: ${event.httpMethod}`); 20 | } 21 | 22 | console.info('chatEventHandler received:', event); 23 | 24 | // Parse the incoming request body 25 | const body = JSON.parse(event.body); 26 | const { arn, eventAttributes, eventName } = body; 27 | 28 | // Construct parameters. 29 | // Documentation is available at https://docs.aws.amazon.com/ivs/latest/ChatAPIReference/Welcome.html 30 | const params = { 31 | "roomIdentifier": `${arn}`, 32 | "eventName": eventName, 33 | "attributes": { ...eventAttributes } 34 | }; 35 | 36 | try { 37 | await IVSChat.sendEvent(params).promise(); 38 | console.info("chatEventHandler > IVSChat.sendEvent > Success"); 39 | response.statusCode = 200; 40 | // If sendEvent() is successfull, it will return an empty response. 41 | // For the purposes of this API however, let's return "success" in the response body 42 | response.body = JSON.stringify({ 43 | arn: `${arn}`, 44 | status: "success" 45 | }); 46 | } catch (err) { 47 | console.error('ERROR: chatEventHandler > IVSChat.sendEvent:', err); 48 | response.statusCode = 500; 49 | response.body = err.stack; 50 | } 51 | 52 | console.info(`response from: ${event.path} statusCode: ${response.statusCode} body: ${response.body}`); 53 | return response; 54 | } 55 | -------------------------------------------------------------------------------- /web-ui/src/components/chat/StickerPicker.jsx: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | import React, { useState } from 'react'; 5 | import PropTypes from 'prop-types'; 6 | import { STICKERS } from '../../constants'; 7 | 8 | const StickerPicker = ({ handleStickerSend }) => { 9 | const [showStickers, setShowStickers] = useState(false); 10 | 11 | return ( 12 | <> 13 | 31 |
32 | {STICKERS.map((sticker) => { 33 | return ( 34 | 49 | ); 50 | })} 51 |
52 | 53 | ); 54 | }; 55 | 56 | StickerPicker.propTypes = { 57 | handleStickerSend: PropTypes.func, 58 | }; 59 | 60 | export default StickerPicker; 61 | -------------------------------------------------------------------------------- /web-ui/src/components/chat/Avatars.jsx: -------------------------------------------------------------------------------- 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 PropTypes from "prop-types"; 6 | import { AVATARS } from "../../constants"; 7 | 8 | const Avatars = ({ handleAvatarClick, currentAvatar }) => { 9 | return ( 10 | <> 11 | {AVATARS.map((avatar) => { 12 | const selected = avatar.name === currentAvatar ? " selected" : ""; 13 | return ( 14 | 52 | ); 53 | })} 54 | 55 | ); 56 | }; 57 | 58 | Avatars.propTypes = { 59 | currentAvatar: PropTypes.string, 60 | handleAvatarClick: PropTypes.func, 61 | }; 62 | 63 | export default Avatars; 64 | -------------------------------------------------------------------------------- /web-ui/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 | 20 | 21 | 30 | 31 | Amazon IVS Chat Demo - Web 32 | 33 | 34 | 35 |
36 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /web-ui/src/constants.js: -------------------------------------------------------------------------------- 1 | // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | // SPDX-License-Identifier: MIT-0 3 | 4 | export const AVATARS = [ 5 | { 6 | name: "bear", 7 | src: "https://d39ii5l128t5ul.cloudfront.net/assets/animals_square/bear.png", 8 | }, 9 | { 10 | name: "bird", 11 | src: "https://d39ii5l128t5ul.cloudfront.net/assets/animals_square/bird.png", 12 | }, 13 | { 14 | name: "bird2", 15 | src: "https://d39ii5l128t5ul.cloudfront.net/assets/animals_square/bird2.png", 16 | }, 17 | { 18 | name: "dog", 19 | src: "https://d39ii5l128t5ul.cloudfront.net/assets/animals_square/dog.png", 20 | }, 21 | { 22 | name: "giraffe", 23 | src: "https://d39ii5l128t5ul.cloudfront.net/assets/animals_square/giraffe.png", 24 | }, 25 | { 26 | name: "hedgehog", 27 | src: "https://d39ii5l128t5ul.cloudfront.net/assets/animals_square/hedgehog.png", 28 | }, 29 | { 30 | name: "hippo", 31 | src: "https://d39ii5l128t5ul.cloudfront.net/assets/animals_square/hippo.png", 32 | } 33 | ]; 34 | 35 | export const STICKERS = [ 36 | { 37 | name: "cute", 38 | src: "https://d39ii5l128t5ul.cloudfront.net/assets/chat/v1/sticker-1.png" 39 | }, 40 | { 41 | name: "angry", 42 | src: "https://d39ii5l128t5ul.cloudfront.net/assets/chat/v1/sticker-2.png" 43 | }, 44 | { 45 | name: "sad", 46 | src: "https://d39ii5l128t5ul.cloudfront.net/assets/chat/v1/sticker-3.png" 47 | }, 48 | { 49 | name: "happy", 50 | src: "https://d39ii5l128t5ul.cloudfront.net/assets/chat/v1/sticker-4.png" 51 | }, 52 | { 53 | name: "surprised", 54 | src: "https://d39ii5l128t5ul.cloudfront.net/assets/chat/v1/sticker-5.png" 55 | }, 56 | { 57 | name: "cool", 58 | src: "https://d39ii5l128t5ul.cloudfront.net/assets/chat/v1/sticker-6.png" 59 | }, 60 | { 61 | name: "love", 62 | src: "https://d39ii5l128t5ul.cloudfront.net/assets/chat/v1/sticker-7.png" 63 | }, 64 | { 65 | name: "rocket", 66 | src: "https://d39ii5l128t5ul.cloudfront.net/assets/chat/v1/sticker-8.png" 67 | }, 68 | { 69 | name: "confetti", 70 | src: "https://d39ii5l128t5ul.cloudfront.net/assets/chat/v1/sticker-9.png" 71 | }, 72 | { 73 | name: "camera", 74 | src: "https://d39ii5l128t5ul.cloudfront.net/assets/chat/v1/sticker-10.png" 75 | } 76 | ] -------------------------------------------------------------------------------- /serverless/src/chat-auth.js: -------------------------------------------------------------------------------- 1 | const AWS = require("aws-sdk"); 2 | const IVSChat = new AWS.Ivschat(); 3 | 4 | const response = { 5 | statusCode: 200, 6 | headers: { 7 | "Access-Control-Allow-Headers" : "Content-Type", 8 | "Access-Control-Allow-Origin": "*", 9 | "Access-Control-Allow-Methods": "POST" 10 | }, 11 | body: "" 12 | }; 13 | 14 | /** 15 | * A function that generates an IVS chat authentication token based on the request parameters. 16 | */ 17 | exports.chatAuthHandler = async (event) => { 18 | if (event.httpMethod !== 'POST') { 19 | throw new Error(`chatAuthHandler only accepts POST method, you tried: ${event.httpMethod}`); 20 | } 21 | 22 | console.info('chatAuthHandler received:', event); 23 | 24 | // Parse the incoming request body 25 | const body = JSON.parse(event.body); 26 | const { arn, roomIdentifier, userId } = body; 27 | const roomId = arn || roomIdentifier; 28 | const additionalAttributes = body.attributes || {}; 29 | const capabilities = body.capabilities || []; // The permission to view messages is implicit 30 | const durationInMinutes = body.durationInMinutes || 55; // default the expiration to 55 mintues 31 | 32 | if (!roomId || !userId) { 33 | response.statusCode = 400; 34 | response.body = { error: 'Missing parameters: `arn or roomIdentifier`, `userId`' }; 35 | return response; 36 | } 37 | 38 | // Construct parameters. 39 | // Documentation is available at https://docs.aws.amazon.com/ivs/latest/ChatAPIReference/Welcome.html 40 | const params = { 41 | roomIdentifier: `${roomId}`, 42 | userId: `${userId}`, 43 | attributes: { ...additionalAttributes }, 44 | capabilities: capabilities, 45 | sessionDurationInMinutes: durationInMinutes, 46 | }; 47 | 48 | try { 49 | const data = await IVSChat.createChatToken(params).promise(); 50 | console.info("Got data:", data); 51 | response.statusCode = 200; 52 | response.body = JSON.stringify(data); 53 | } catch (err) { 54 | console.error('ERROR: chatAuthHandler > IVSChat.createChatToken:', err); 55 | response.statusCode = 500; 56 | response.body = err.stack; 57 | } 58 | 59 | console.info(`response from: ${event.path} statusCode: ${response.statusCode} body: ${response.body}`); 60 | return response; 61 | } 62 | -------------------------------------------------------------------------------- /serverless/template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: "2010-09-09" 2 | Transform: "AWS::Serverless-2016-10-31" 3 | Description: Amazon IVS Simple Chat Backend 4 | 5 | Globals: 6 | Api: 7 | Cors: 8 | AllowMethods: "'GET,POST,OPTIONS'" 9 | AllowHeaders: "'*'" 10 | AllowOrigin: "'*'" 11 | Function: 12 | Runtime: nodejs18.x 13 | Timeout: 30 14 | MemorySize: 128 15 | 16 | Resources: 17 | IvsChatLambdaRefLayer: 18 | Type: AWS::Serverless::LayerVersion 19 | Properties: 20 | LayerName: sam-app-dependencies 21 | Description: Dependencies for sam app [ivs-simple-chat-backend] 22 | ContentUri: dependencies/ 23 | CompatibleRuntimes: 24 | - nodejs18.x 25 | LicenseInfo: "MIT" 26 | RetentionPolicy: Retain 27 | # This is a Lambda function config associated with the source code: chat-auth.js 28 | chatAuthFunction: 29 | Type: AWS::Serverless::Function 30 | Properties: 31 | Handler: src/chat-auth.chatAuthHandler 32 | Description: A function that generates an IVS chat authentication token based on the request parameters. 33 | Layers: 34 | - !Ref IvsChatLambdaRefLayer 35 | Policies: 36 | - Statement: 37 | Effect: Allow 38 | Action: 39 | - ivschat:* 40 | Resource: "*" 41 | Events: 42 | Api: 43 | Type: Api 44 | Properties: 45 | Path: /auth 46 | Method: POST 47 | 48 | # This is a Lambda function config associated with the source code: chat-event.js 49 | chatEventFunction: 50 | Type: AWS::Serverless::Function 51 | Properties: 52 | Handler: src/chat-event.chatEventHandler 53 | Description: A function that sends an event to a specified IVS chat room 54 | Layers: 55 | - !Ref IvsChatLambdaRefLayer 56 | Policies: 57 | - Statement: 58 | Effect: Allow 59 | Action: 60 | - ivschat:* 61 | Resource: "*" 62 | Events: 63 | Api: 64 | Type: Api 65 | Properties: 66 | Path: /event 67 | Method: POST 68 | 69 | # This is a Lambda function config associated with the source code: chat-event.js 70 | chatListFunction: 71 | Type: AWS::Serverless::Function 72 | Properties: 73 | Handler: src/chat-list.chatListHandler 74 | Description: A function that returns a list of available chat rooms 75 | Layers: 76 | - !Ref IvsChatLambdaRefLayer 77 | Policies: 78 | - Statement: 79 | Effect: Allow 80 | Action: 81 | - ivschat:* 82 | Resource: "*" 83 | Events: 84 | Api: 85 | Type: Api 86 | Properties: 87 | Path: /list 88 | Method: GET 89 | 90 | Outputs: 91 | ApiURL: 92 | Description: "API endpoint URL for Prod environment" 93 | Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/" 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Amazon IVS Chat Web Demo 2 | 3 | A demo web application intended as an educational tool to demonstrate how you can build a simple live video and chat application with [Amazon IVS](https://aws.amazon.com/ivs/). 4 | 5 | Amazon IVS Chat Web Demo 6 | 7 | **This project is intended for education purposes only and not for production usage.** 8 | 9 | This is a serverless web application, leveraging [Amazon IVS](https://aws.amazon.com/ivs/) for video streaming and chat, [AWS Lambda](https://aws.amazon.com/lambda/), and [Amazon API Gateway](https://aws.amazon.com/api-gateway/). The web user interface is written in Javascript and built on React. 10 | 11 | The demo showcases how you can implement a simple live streaming application with video and chat using Amazon IVS. Viewers are asked to enter their name the first time they begin chatting. Chat users can send plain text messages, text links, emojis, and stickers. Chat moderators can delete messages and kick users. 12 | 13 | ## Getting Started 14 | 15 | This demo is comprised of two parts: `serverless` (the demo backend) and `web-ui` (the demo frontend). 16 | 17 | 1. If you do not have an AWS account, you can create one by following this guide: [How do I create and activate a new Amazon Web Services account?](https://aws.amazon.com/premiumsupport/knowledge-center/create-and-activate-aws-account/) 18 | 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. 19 | 3. Deploy the [serverless backend](./serverless/README.md) to your AWS account. The CloudFormation template will automate the deployment process. 20 | 21 | ## Known issues and limitations 22 | 23 | * The application is meant for demonstration purposes and **not** for production use. 24 | * This application is only tested in the us-west-2 (Oregon) region. Additional regions may be supported depending on service availability. 25 | 26 | ## About Amazon IVS 27 | Amazon Interactive Video Service (Amazon IVS) is a managed live streaming and stream chat solution that is quick and easy to set up, and ideal for creating interactive video experiences. [Learn more](https://aws.amazon.com/ivs/). 28 | 29 | * [Amazon IVS docs](https://docs.aws.amazon.com/ivs/) 30 | * [User Guide](https://docs.aws.amazon.com/ivs/latest/userguide/) 31 | * [API Reference](https://docs.aws.amazon.com/ivs/latest/APIReference/) 32 | * [Setting Up for Streaming with Amazon Interactive Video Service](https://aws.amazon.com/blogs/media/setting-up-for-streaming-with-amazon-ivs/) 33 | * [Learn more about Amazon IVS on IVS.rocks](https://ivs.rocks/) 34 | * [View more demos like this](https://ivs.rocks/examples) 35 | 36 | ## Security 37 | 38 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 39 | 40 | ## License 41 | 42 | This library is licensed under the MIT-0 License. See the LICENSE file. -------------------------------------------------------------------------------- /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 Avatars from "./Avatars"; 6 | 7 | const SignIn = ({ handleSignIn }) => { 8 | const [username, setUsername] = useState(""); 9 | const [moderator, setModerator] = useState(false); 10 | const [avatar, setAvatar] = useState({}); 11 | const [loaded, setLoaded] = useState(false); 12 | const inputRef = createRef(); 13 | 14 | useEffect(() => { 15 | setLoaded(true); 16 | inputRef.current.focus(); 17 | }, [loaded]); // eslint-disable-line 18 | 19 | return ( 20 |
21 |
22 |

Join the chat room

23 |
{e.preventDefault()}}> 24 |
25 | 28 | { 38 | e.preventDefault(); 39 | setUsername(e.target.value); 40 | }} 41 | /> 42 |
43 |
Select Avatar
44 |
45 |
46 | { 49 | setAvatar(avatar); 50 | }} 51 | /> 52 |
53 |
54 |
55 |
56 | { 63 | setModerator(e.target.checked); 64 | }} 65 | /> 66 | 67 |
68 |
69 | 78 |
79 |
80 |
81 |
82 |
83 | ); 84 | }; 85 | 86 | export default SignIn; 87 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | 6 | // Styles 7 | import './VideoPlayer.css'; 8 | 9 | const VideoPlayer = ({ 10 | usernameRaisedHand, 11 | showRaiseHandPopup, 12 | playbackUrl, 13 | }) => { 14 | useEffect(() => { 15 | const MediaPlayerPackage = window.IVSPlayer; 16 | 17 | // First, check if the browser supports the Amazon IVS player. 18 | if (!MediaPlayerPackage.isPlayerSupported) { 19 | console.warn( 20 | 'The current browser does not support the Amazon IVS player.' 21 | ); 22 | return; 23 | } 24 | 25 | const PlayerState = MediaPlayerPackage.PlayerState; 26 | const PlayerEventType = MediaPlayerPackage.PlayerEventType; 27 | 28 | // Initialize player 29 | const player = MediaPlayerPackage.create(); 30 | player.attachHTMLVideoElement(document.getElementById('video-player')); 31 | 32 | // Attach event listeners 33 | player.addEventListener(PlayerState.PLAYING, () => { 34 | console.info('Player State - PLAYING'); 35 | }); 36 | player.addEventListener(PlayerState.ENDED, () => { 37 | console.info('Player State - ENDED'); 38 | }); 39 | player.addEventListener(PlayerState.READY, () => { 40 | console.info('Player State - READY'); 41 | }); 42 | player.addEventListener(PlayerEventType.ERROR, (err) => { 43 | console.warn('Player Event - ERROR:', err); 44 | }); 45 | 46 | // Setup stream and play 47 | player.setAutoplay(true); 48 | player.load(playbackUrl); 49 | player.setVolume(0.5); 50 | }, []); // eslint-disable-line 51 | 52 | return ( 53 | <> 54 |
55 |
56 | 62 |
63 | {showRaiseHandPopup ? ( 64 |
65 |
66 | 71 | 72 | 73 |
74 | {usernameRaisedHand} raised their hand 75 |
76 | ) : ( 77 | <> 78 | )} 79 |
80 |
81 |
82 | 83 | ); 84 | }; 85 | 86 | export default VideoPlayer; 87 | -------------------------------------------------------------------------------- /web-ui/README.md: -------------------------------------------------------------------------------- 1 | # Amazon IVS Chat Web Demo Frontend 2 | 3 | ## Prerequisites 4 | 5 | * [NodeJS](https://nodejs.org/) 6 | * Npm is installed with Node.js 7 | * Amazon IVS Chat Demo backend (see [README.md](../serverless) in the `serverless` folder for details on the configuration) 8 | 9 | ## Configuration 10 | 11 | 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). 12 | 13 | * `PLAYBACK_URL` 14 | * Amazon IVS live video stream to play inside the video player 15 | * `CHAT_REGION` 16 | * The AWS region of the chat room. For example, `us-west-2`. 17 | * `API_URL` 18 | * Endpoint for the [Amazon IVS Chat Demo](../serverless) Backend 19 | * `CHAT_ROOM_ID` 20 | * The ID (or ARN) of the Amazon IVS Chat Room that the app should use. 21 | * You must create an Amazon IVS Chat Room to get a chat room ID/ARN. Refer to [Getting Started with Amazon IVS Chat](https://docs.aws.amazon.com/ivs/latest/userguide/getting-started-chat.html) for a detailed guide. 22 | 23 | ## Running the demo 24 | 25 | After you are done configuring the app, follow these instructions to run the demo: 26 | 27 | 1. [Install NodeJS](https://nodejs.org/). Download latest LTS version ("Recommended for Most Users") 28 | 2. Navigate to the web-ui project directory on your local computer 29 | 3. Run: npm install 30 | 4. Run: npm start 31 | 32 | ## Limitations 33 | 34 | * Message and user data for this demo is not saved/persistent (ie. reloading the page would go back to initial state). 35 | 36 | -------------------------------------------------- 37 | 38 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 39 | 40 | ## Available Scripts 41 | 42 | In the project directory, you can run: 43 | 44 | ### `npm start` 45 | 46 | Runs the app in the development mode.
47 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 48 | 49 | The page will reload if you make edits.
50 | You will also see any lint errors in the console. 51 | 52 | ### `npm test` 53 | 54 | Launches the test runner in the interactive watch mode.
55 | See the section about [running tests](https://create-react-app.dev/docs/running-tests/) for more information. 56 | 57 | ### `npm run build` 58 | 59 | Builds the app for production to the `build` folder.
60 | It correctly bundles React in production mode and optimizes the build for the best performance. 61 | 62 | The build is minified and the filenames include the hashes.
63 | Your app is ready to be deployed! 64 | 65 | See the section about [deployment](https://create-react-app.dev/docs/deployment/) for more information. 66 | 67 | ## Learn More 68 | 69 | You can learn more in the [Create React App documentation](https://create-react-app.dev/docs/getting-started/). 70 | To learn React, check out the [React documentation](https://reactjs.org/). 71 | 72 | ### Code Splitting 73 | 74 | https://create-react-app.dev/docs/code-splitting/ 75 | 76 | ### Analyzing the Bundle Size 77 | 78 | https://create-react-app.dev/docs/analyzing-the-bundle-size/ 79 | 80 | ### Making a Progressive Web App 81 | 82 | https://create-react-app.dev/docs/making-a-progressive-web-app/ 83 | 84 | ### Advanced Configuration 85 | 86 | https://create-react-app.dev/docs/advanced-configuration/ 87 | 88 | ### Deployment 89 | 90 | https://create-react-app.dev/docs/deployment/ 91 | 92 | ### `npm run build` fails to minify 93 | 94 | https://create-react-app.dev/docs/troubleshooting/#npm-run-build-fails-to-minify 95 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.github/workflows/update-ivs-sdks.yml: -------------------------------------------------------------------------------- 1 | name: Update Amazon IVS SDKs 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' # Run daily at midnight UTC 6 | workflow_dispatch: # Allow manual triggering 7 | 8 | jobs: 9 | update-ivs-sdks: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | pull-requests: write 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Setup Node.js 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: '20' 21 | 22 | - name: Check for Player SDK updates 23 | id: check-player 24 | run: | 25 | # Extract current version from index.html 26 | CURRENT_VERSION=$(grep -oP 'player\.live-video\.net/\K[0-9]+\.[0-9]+\.[0-9]+' web-ui/public/index.html) 27 | echo "Current Player SDK version: $CURRENT_VERSION" 28 | 29 | # Get latest version from npm 30 | LATEST_VERSION=$(npm view amazon-ivs-player version) 31 | echo "Latest Player SDK version: $LATEST_VERSION" 32 | 33 | # Compare versions 34 | if [ "$CURRENT_VERSION" != "$LATEST_VERSION" ]; then 35 | echo "Player SDK update available: $CURRENT_VERSION -> $LATEST_VERSION" 36 | echo "has_update=true" >> $GITHUB_OUTPUT 37 | echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT 38 | echo "new_version=$LATEST_VERSION" >> $GITHUB_OUTPUT 39 | 40 | # Update the script src in index.html 41 | sed -i "s|player\.live-video\.net/$CURRENT_VERSION|player.live-video.net/$LATEST_VERSION|g" web-ui/public/index.html 42 | else 43 | echo "No Player SDK update available" 44 | echo "has_update=false" >> $GITHUB_OUTPUT 45 | fi 46 | 47 | - name: Check for Chat Messaging SDK updates 48 | id: check-chat 49 | run: | 50 | cd web-ui 51 | 52 | # Extract current version from package.json 53 | CURRENT_VERSION=$(node -p "require('./package.json').dependencies['amazon-ivs-chat-messaging']" | sed 's/[\^~]//g') 54 | echo "Current Chat Messaging SDK version: $CURRENT_VERSION" 55 | 56 | # Get latest version from npm 57 | LATEST_VERSION=$(npm view amazon-ivs-chat-messaging version) 58 | echo "Latest Chat Messaging SDK version: $LATEST_VERSION" 59 | 60 | # Compare versions 61 | if [ "$CURRENT_VERSION" != "$LATEST_VERSION" ]; then 62 | echo "Chat Messaging SDK update available: $CURRENT_VERSION -> $LATEST_VERSION" 63 | echo "has_update=true" >> $GITHUB_OUTPUT 64 | echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT 65 | echo "new_version=$LATEST_VERSION" >> $GITHUB_OUTPUT 66 | 67 | # Update package.json 68 | npm install amazon-ivs-chat-messaging@$LATEST_VERSION --save-exact 69 | # Change back to caret notation to match existing style 70 | sed -i "s|\"amazon-ivs-chat-messaging\": \"$LATEST_VERSION\"|\"amazon-ivs-chat-messaging\": \"^$LATEST_VERSION\"|g" package.json 71 | else 72 | echo "No Chat Messaging SDK update available" 73 | echo "has_update=false" >> $GITHUB_OUTPUT 74 | fi 75 | 76 | - name: Prepare PR details 77 | id: pr-details 78 | run: | 79 | PLAYER_UPDATE="${{ steps.check-player.outputs.has_update }}" 80 | CHAT_UPDATE="${{ steps.check-chat.outputs.has_update }}" 81 | 82 | if [ "$PLAYER_UPDATE" = "true" ] || [ "$CHAT_UPDATE" = "true" ]; then 83 | echo "has_changes=true" >> $GITHUB_OUTPUT 84 | 85 | # Build commit message and PR title 86 | if [ "$PLAYER_UPDATE" = "true" ] && [ "$CHAT_UPDATE" = "true" ]; then 87 | TITLE="chore: update Amazon IVS Player SDK to ${{ steps.check-player.outputs.new_version }} and Chat Messaging SDK to ${{ steps.check-chat.outputs.new_version }}" 88 | BODY="This PR updates both Amazon IVS SDKs: 89 | 90 | - **Player SDK**: ${{ steps.check-player.outputs.current_version }} → ${{ steps.check-player.outputs.new_version }} 91 | - **Chat Messaging SDK**: ${{ steps.check-chat.outputs.current_version }} → ${{ steps.check-chat.outputs.new_version }} 92 | 93 | ## Changes 94 | - Updated Player SDK CDN URL in \`web-ui/public/index.html\` 95 | - Updated Chat Messaging SDK version in \`web-ui/package.json\` 96 | 97 | This update was automatically generated by the dependency update workflow." 98 | elif [ "$PLAYER_UPDATE" = "true" ]; then 99 | TITLE="chore: update Amazon IVS Player SDK to ${{ steps.check-player.outputs.new_version }}" 100 | BODY="This PR updates the Amazon IVS Player SDK from ${{ steps.check-player.outputs.current_version }} to ${{ steps.check-player.outputs.new_version }}. 101 | 102 | ## Changes 103 | - Updated Player SDK CDN URL in \`web-ui/public/index.html\` 104 | 105 | This update was automatically generated by the dependency update workflow." 106 | else 107 | TITLE="chore: update amazon-ivs-chat-messaging to ${{ steps.check-chat.outputs.new_version }}" 108 | BODY="This PR updates the amazon-ivs-chat-messaging package from ${{ steps.check-chat.outputs.current_version }} to ${{ steps.check-chat.outputs.new_version }}. 109 | 110 | ## Changes 111 | - Updated Chat Messaging SDK version in \`web-ui/package.json\` 112 | 113 | This update was automatically generated by the dependency update workflow." 114 | fi 115 | 116 | # Use environment files for multiline output 117 | echo "PR_TITLE=$TITLE" >> $GITHUB_ENV 118 | echo "PR_BODY<> $GITHUB_ENV 119 | echo "$BODY" >> $GITHUB_ENV 120 | echo "EOF" >> $GITHUB_ENV 121 | else 122 | echo "has_changes=false" >> $GITHUB_OUTPUT 123 | fi 124 | 125 | - name: Create Pull Request 126 | if: steps.pr-details.outputs.has_changes == 'true' 127 | uses: peter-evans/create-pull-request@v6 128 | with: 129 | commit-message: ${{ env.PR_TITLE }} 130 | title: ${{ env.PR_TITLE }} 131 | body: ${{ env.PR_BODY }} 132 | branch: dependency-update/ivs-sdks 133 | delete-branch: true 134 | labels: dependencies 135 | -------------------------------------------------------------------------------- /serverless/README.md: -------------------------------------------------------------------------------- 1 | # Amazon IVS Chat Demo Backend 2 | 3 | This readme includes instructions for deploying the Amazon IVS Chat Demo backend to an AWS Account. This backend supports the following Amazon IVS Chat Demos: 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 | ## Application overview 10 | 11 | Amazon IVS Chat Demo Backend Architecture 12 | 13 | The chat demo backend emits event messages and handles encrypted chat tokens, which authorize users to perform actions in your chat room. In this demo, the backend simply accepts whatever information the client provides. When you launch the client app, you’ll be able to pick a username, profile picture, and whether or not you want moderator permissions. In a production setting, the backend application would likely interface with your existing user service to determine the capabilities to grant a client. For example, a client that is marked as a default user in your user database might only be authorized view and send messages, but a client marked as a moderator could also be authorized to delete messages and disconnect users. 14 | 15 | ## Prerequisites 16 | 17 | * [AWS CLI Version 2](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) 18 | * [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html) 19 | * Access to an AWS Account with at least the following permissions: 20 | * Create IAM roles 21 | * Create Lambda Functions 22 | * Authenticate and send Events in Amazon IVS Chat 23 | * Create Amazon S3 Buckets 24 | 25 | ***IMPORTANT NOTE:** Running this demo application in your AWS account will create and consume AWS resources, which will cost money.* Amazon IVS is eligible for the AWS free tier. Visit the Amazon IVS [pricing page](https://aws.amazon.com/ivs/pricing/) for more details. 26 | 27 | ## Run this app locally 28 | 29 | Before you start, run the following command to make sure you're in the correct AWS account (or configure as needed): 30 | 31 | ```bash 32 | aws configure 33 | ``` 34 | 35 | ### 1. Install AWS SDK 36 | 37 | Navigate to `serverless/dependencies/nodejs` and run `npm install` to install the AWS SDK. 38 | 39 | ### 2. Start the local api 40 | 41 | Navigate back to the `serverless` directory and run the following command: 42 | 43 | ```bash 44 | sam local start-api -p 3100 45 | ``` 46 | 47 | For a full list of command flags, refer to the [SAM CLI documentation](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-cli-command-reference-sam-local-start-api.html) 48 | 49 | 50 | ## Deployment instructions 51 | 52 | Before you start, run the following command to make sure you're in the correct AWS account (or configure as needed): 53 | 54 | ```bash 55 | aws configure 56 | ``` 57 | 58 | For configuration specifics, refer to the [AWS CLI User Guide](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-configure.html) 59 | 60 | ### 1. Install AWS SDK 61 | 62 | Navigate to `serverless/dependencies/nodejs` and run `npm install` to install the AWS SDK. 63 | 64 | ### 2. Create an S3 bucket 65 | 66 | * Replace `` with a name for your S3 Bucket. 67 | * Replace `` with your AWS region. The following regions are currently supported: 68 | * us-east-1 69 | * us-west-2 70 | * eu-east-1 71 | 72 | ```bash 73 | aws s3api create-bucket --bucket --region \ 74 | --create-bucket-configuration LocationConstraint= 75 | ``` 76 | 77 | ### 3. Pack template with SAM 78 | 79 | ```bash 80 | sam package --template-file template.yaml \ 81 | --s3-bucket \ 82 | --output-template-file output.yaml 83 | ``` 84 | 85 | DO NOT run the output from above command, proceed to next step. 86 | 87 | ### 4. Deploy the packaged template 88 | 89 | * Replace `` with a name of your choice. The stack name will be used to reference the application. 90 | * Replace `` with the AWS region you entered in Step 1. 91 | 92 | ```bash 93 | sam deploy --template-file ./output.yaml \ 94 | --stack-name \ 95 | --capabilities CAPABILITY_IAM \ 96 | --region 97 | ``` 98 | 99 | ### Use your deployed backend in the client applications 100 | 101 | When the deployment successfully completes, copy the output `ApiURL` for use in the various client application demos: 102 | 103 | * [Amazon IVS Chat Web Demo](https://github.com/aws-samples/amazon-ivs-chat-web-demo) 104 | * [Amazon IVS Chat iOS Demo](https://github.com/aws-samples/amazon-ivs-chat-for-ios-demo) 105 | * [Amazon IVS Chat Android Demo](https://github.com/aws-samples/amazon-ivs-chat-for-android-demo) 106 | 107 | ### Accessing the deployed application 108 | 109 | If needed, you can retrieve the Cloudformation stack outputs by running the following command: 110 | 111 | * Replace `` with the name of your stack from Step 3. 112 | 113 | ```bash 114 | aws cloudformation describe-stacks --stack-name \ 115 | --query 'Stacks[].Outputs' 116 | ``` 117 | 118 | ## Cleanup 119 | 120 | To delete the deployed application, you can use the AWS CLI. You can also visit the [AWS Cloudformation Console](https://us-west-2.console.aws.amazon.com/cloudformation/home) to manage your application. 121 | 122 | Delete Cloudformation stack: 123 | 124 | ```bash 125 | aws cloudformation delete-stack --stack-name 126 | ``` 127 | 128 | Remove files in S3 bucket 129 | 130 | ```bash 131 | aws s3 rm s3:// --recursive 132 | ``` 133 | 134 | Delete S3 bucket 135 | 136 | ```bash 137 | aws s3api delete-bucket --bucket --region 138 | ``` 139 | 140 | ## Resources 141 | 142 | For an introduction to the AWS SAM specification, the AWS SAM CLI, and serverless application concepts, see the [AWS SAM Developer Guide](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html). 143 | 144 | Next, you can use the AWS Serverless Application Repository to deploy ready-to-use apps that go beyond Hello World samples and learn how authors developed their applications. For more information, see the [AWS Serverless Application Repository main page](https://aws.amazon.com/serverless/serverlessrepo/) and the [AWS Serverless Application Repository Developer Guide](https://docs.aws.amazon.com/serverlessrepo/latest/devguide/what-is-serverlessrepo.html). 145 | -------------------------------------------------------------------------------- /THIRD-PARTY-LICENSES.txt: -------------------------------------------------------------------------------- 1 | The Amazon IVS Chat for Web Demo includes the following third-party software/licensing: 2 | 3 | ** Material-design-icons - https://github.com/google/material-design-icons 4 | 5 | Apache License 6 | Version 2.0, January 2004 7 | http://www.apache.org/licenses/ 8 | 9 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 10 | 11 | 1. Definitions. 12 | 13 | "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. 14 | 15 | "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. 16 | 17 | "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. 18 | 19 | "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. 20 | 21 | "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. 22 | 23 | "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. 24 | 25 | "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). 26 | 27 | "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. 28 | 29 | "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." 30 | 31 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 32 | 33 | 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 34 | 35 | 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 36 | 37 | 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: 38 | 39 | You must give any other recipients of the Work or Derivative Works a copy of this License; and 40 | You must cause any modified files to carry prominent notices stating that You changed the files; and 41 | You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and 42 | If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. 43 | 44 | You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 45 | 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 46 | 47 | 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 48 | 49 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 50 | 51 | 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 52 | 53 | 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. 54 | 55 | END OF TERMS AND CONDITIONS 56 | -------------------------------------------------------------------------------- /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-modal-overlay: rgba(185, 185, 192, 0.9); 8 | --color-bg-chat-sticker: #fee77f; 9 | --chat-width: 600px; 10 | --sticker-columns: repeat(5, 1fr); 11 | --hand-raise-transform: translateY(-0.4rem); 12 | } 13 | 14 | .main { 15 | height: calc(100vh - var(--header-height)); 16 | } 17 | 18 | .content-wrapper { 19 | width: 100%; 20 | height: 100%; 21 | background: var(--color-bg-chat); 22 | display: flex; 23 | flex-direction: row; 24 | position: relative; 25 | align-items: stretch; 26 | margin: 0 auto; 27 | } 28 | 29 | .player-wrapper { 30 | width: 100%; 31 | background: black; 32 | position: relative; 33 | overflow: hidden; 34 | display: flex; 35 | justify-content: center; 36 | align-items: center; 37 | } 38 | 39 | .raise-hand { 40 | width: 100%; 41 | height: 65px; 42 | color: #0080bf; 43 | background: #ccf9ff; 44 | border-radius: 30px; 45 | position: absolute; 46 | display: flex; 47 | justify-content: center; 48 | align-items: center; 49 | bottom: 5px; 50 | } 51 | 52 | .aspect-spacer { 53 | padding-bottom: 56.25%; 54 | } 55 | 56 | .el-player { 57 | width: 100%; 58 | height: 100%; 59 | position: absolute; 60 | top: 0; 61 | background: #000; 62 | } 63 | 64 | .col-wrapper { 65 | width: var(--chat-width); 66 | background: var(--color-bg-chat); 67 | flex-shrink: 0; 68 | align-self: stretch; 69 | position: relative; 70 | } 71 | 72 | .hidden { 73 | display: none !important; 74 | } 75 | 76 | .btn:disabled { 77 | opacity: 0.5; 78 | background: var(--color-bg-button-primary-default); 79 | } 80 | 81 | .chat-line-btn > svg { 82 | fill: currentColor; 83 | } 84 | 85 | .input-line-btn { 86 | padding: 0; 87 | margin: 0; 88 | width: var(--input-height); 89 | height: var(--input-height); 90 | border-radius: var(--input-height); 91 | overflow: hidden; 92 | margin: 0 5px 5px 0; 93 | flex-shrink: 0; 94 | border: 2px solid transparent; 95 | display: inline-flex; 96 | justify-content: center; 97 | align-items: center; 98 | fill: currentColor; 99 | color: var(--color-text-hint); 100 | } 101 | 102 | .raise-hand-btn { 103 | fill: currentColor; 104 | position: relative; 105 | overflow: visible; 106 | } 107 | 108 | .raise-hand-btn:before { 109 | position: absolute; 110 | display: flex; 111 | align-items: center; 112 | justify-content: center; 113 | content: '\2191'; /* up arrow */ 114 | top: -0.5rem; 115 | right: -0.5rem; 116 | width: 1.8rem; 117 | height: 1.8rem; 118 | z-index: 9; 119 | border-radius: 1.2rem; 120 | font-size: 1.2rem; 121 | color: inherit; 122 | background: inherit; 123 | border: 2px solid var(--color-bg-chat); 124 | font-weight: bold; 125 | transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1); 126 | transform: translateY(0rem); 127 | } 128 | 129 | .raise-hand-btn:hover:before { 130 | background: var(--color--positive); 131 | color: var(--color-text-inverted); 132 | transform: translateY(0rem); 133 | } 134 | 135 | .raise-hand-btn:focus:before { 136 | border-color: var(--color-bg-primary); 137 | } 138 | 139 | .raise-hand-btn--raised { 140 | background-color: var(--color-bg-inverted); 141 | color: var(--color-text-inverted); 142 | } 143 | 144 | .raise-hand-btn--raised:before { 145 | content: '\2193'; /* down arrow */ 146 | background: var(--color--destructive); 147 | transform: var(--hand-raise-transform); 148 | } 149 | 150 | .raise-hand-btn--raised:hover:before { 151 | background: var(--color--destructive); 152 | color: var(--color-text-inverted); 153 | transform: translateY(0rem); 154 | } 155 | 156 | .raise-hand-btn--raised:focus:before, 157 | .raise-hand-btn--raised:hover:before { 158 | color: var(--color-text-inverted); 159 | } 160 | 161 | .input-line-btn:hover { 162 | color: var(--color-text-base); 163 | background-color: var(--color-bg-button-hover); 164 | } 165 | 166 | .input-line-btn:focus { 167 | color: var(--color-text-base); 168 | border-color: var(--color-bg-primary); 169 | background: var(--color-bg-button-focus); 170 | } 171 | 172 | /* Chat */ 173 | .chat-wrapper { 174 | position: absolute; 175 | top: 0; 176 | bottom: 0; 177 | left: 0; 178 | right: 0; 179 | padding-bottom: calc(var(--input-height) + 3rem); 180 | display: flex; 181 | flex-direction: column; 182 | align-items: flex-start; 183 | } 184 | 185 | .chat-wrapper .messages { 186 | height: 100%; 187 | width: 100%; 188 | overflow-y: auto; 189 | display: flex; 190 | flex-direction: column; 191 | align-items: flex-start; 192 | padding: 1rem 1.5rem; 193 | } 194 | 195 | .composer button.btn { 196 | margin-bottom: 0; 197 | } 198 | 199 | .error-line { 200 | padding: 6px 15px; 201 | background: var(--color-bg-destructive); 202 | border-radius: var(--input-height); 203 | display: flex; 204 | margin: 0 0 5px 0; 205 | } 206 | 207 | .error-line p { 208 | font-size: 1.2rem; 209 | display: inline; 210 | font-weight: bold; 211 | color: white; 212 | } 213 | 214 | .success-line { 215 | padding: 6px 15px; 216 | background: var(--color-bg-positive); 217 | border-radius: var(--input-height); 218 | display: flex; 219 | margin: 0 0 5px 0; 220 | } 221 | 222 | .success-line p { 223 | font-size: 1.2rem; 224 | display: inline; 225 | font-weight: bold; 226 | color: white; 227 | } 228 | 229 | .chat-line { 230 | flex-grow: 1; 231 | padding: 1.2rem 1.6rem 1.2rem 1.2rem; 232 | background: var(--color-bg-chat-bubble); 233 | border-radius: 2.4rem; 234 | display: flex; 235 | align-items: center; 236 | margin: 0 0.5rem 0.5rem 0; 237 | } 238 | 239 | .chat-line p { 240 | display: inline; 241 | font-weight: normal; 242 | } 243 | 244 | .chat-line .username { 245 | font-weight: 800; 246 | padding-right: 0.1rem; 247 | } 248 | 249 | .chat-line .username::after { 250 | content: '\00a0 '; 251 | } 252 | 253 | .chat-line--sticker { 254 | background: var(--color-bg-chat-sticker); 255 | will-change: transform; 256 | animation: scaleIn 250ms cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; 257 | } 258 | 259 | .chat-line-wrapper { 260 | display: flex; 261 | align-items: flex-start; 262 | } 263 | 264 | .chat-line-sticker-wrapper { 265 | display: flex; 266 | align-items: flex-start; 267 | } 268 | 269 | .chat-line-actions { 270 | flex-shrink: 0; 271 | height: 100%; 272 | display: flex; 273 | align-items: flex-start; 274 | } 275 | 276 | .chat-line-actions button:first-child { 277 | margin-right: 5px; 278 | } 279 | 280 | .chat-line-img { 281 | margin: 0; 282 | padding: 0; 283 | width: 2.4rem; 284 | height: 2.4rem; 285 | border-radius: 1.2rem; 286 | overflow: hidden; 287 | margin-right: 0.5rem; 288 | display: inline; 289 | flex-shrink: 0; 290 | border: 2px solid transparent; 291 | } 292 | 293 | .chat-line-btn { 294 | padding: 0; 295 | margin: 0; 296 | width: 4.8rem; 297 | height: 4.8rem; 298 | border-radius: 2.4rem; 299 | overflow: hidden; 300 | margin: 0 5px 5px 0; 301 | flex-shrink: 0; 302 | border: 2px solid transparent; 303 | display: inline-flex; 304 | justify-content: center; 305 | align-items: center; 306 | color: var(--color-text-hint); 307 | } 308 | 309 | .chat-line-btn:hover { 310 | color: var(--color-text-destructive); 311 | background: var(--color-bg-button-hover); 312 | } 313 | 314 | .chat-line-btn:focus { 315 | color: var(--color-text-destructive); 316 | border-color: var(--color-bg-primary); 317 | background: var(--color-bg-button-focus); 318 | } 319 | 320 | .composer { 321 | position: absolute; 322 | bottom: 0; 323 | left: 0; 324 | right: 0; 325 | padding: 1rem 1.5rem; 326 | background: var(--color-bg-chat); 327 | } 328 | 329 | .composer input { 330 | width: 100%; 331 | } 332 | 333 | .chat-sticker { 334 | width: 10rem; 335 | height: 10rem; 336 | object-fit: contain; 337 | display: inline; 338 | animation: scaleIn 500ms cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; 339 | } 340 | 341 | .stickers-container { 342 | position: absolute; 343 | bottom: calc(var(--input-height) + 2rem); 344 | max-height: 18rem; 345 | overflow-x: hidden; 346 | overflow-y: auto; 347 | right: 0; 348 | left: 0; 349 | padding: 1rem; 350 | margin: 1rem; 351 | display: grid; 352 | grid-template-columns: var(--sticker-columns); 353 | background: var(--color-bg-chat); 354 | border-radius: var(--radius-small); 355 | z-index: 9; 356 | box-shadow: 0 6px 30px rgba(0, 0, 0, 0.08); 357 | } 358 | 359 | .sticker-item { 360 | object-fit: contain; 361 | width: 100%; 362 | height: 100%; 363 | transition: transform 250ms cubic-bezier(0.075, 0.82, 0.165, 1); 364 | } 365 | 366 | .sticker-btn { 367 | width: 100%; 368 | height: 9rem; 369 | padding: 1rem; 370 | display: flex; 371 | flex-shrink: 0; 372 | flex-grow: 1; 373 | align-items: center; 374 | justify-content: center; 375 | background: var(--color-bg-chat); 376 | overflow: hidden; 377 | } 378 | 379 | .sticker-btn:focus, 380 | .sticker-btn:hover { 381 | background: var(--color-bg-button-hover); 382 | } 383 | 384 | .sticker-btn:focus > .sticker-item, 385 | .sticker-btn:hover > .sticker-item { 386 | transform: scale(1.5); 387 | } 388 | 389 | .item-select-container { 390 | width: 100%; 391 | background: var(--color-bg-input); 392 | border-radius: var(--radius-small); 393 | } 394 | 395 | .item-select-grid { 396 | display: grid; 397 | grid-gap: 1rem; 398 | grid-template-columns: repeat(7, 1fr); 399 | } 400 | 401 | .item-select-grid--small { 402 | grid-template-columns: repeat(auto-fit, 5.2rem); 403 | } 404 | 405 | .item-container { 406 | position: relative; 407 | display: flex; 408 | justify-content: center; 409 | border: solid 0.2rem transparent; 410 | overflow: hidden; 411 | border-radius: 50%; 412 | } 413 | 414 | button.item-container { 415 | padding: 0; 416 | margin: 0; 417 | width: 4.8rem; 418 | height: 4.8rem; 419 | } 420 | 421 | .item-container:focus { 422 | border: solid 0.2rem var(--color--primary); 423 | } 424 | 425 | .item-container.selected { 426 | border: solid 0.2rem var(--color--primary); 427 | background: var(--color-bg-body); 428 | } 429 | 430 | .item { 431 | width: 100%; 432 | height: 100%; 433 | position: relative; 434 | } 435 | 436 | .item.selected { 437 | opacity: 0.5; 438 | } 439 | 440 | .icon.selected { 441 | width: 2.4rem; 442 | height: 2.4rem; 443 | } 444 | 445 | .item-selected-wrapper { 446 | position: absolute; 447 | width: 100%; 448 | height: 100%; 449 | display: grid; 450 | place-items: center; 451 | align-content: center; 452 | } 453 | 454 | .item--avatar { 455 | width: 4.8rem; 456 | height: 4.8rem; 457 | } 458 | 459 | @media (max-width: 1440px) { 460 | :root { 461 | --chat-width: 400px; 462 | --sticker-columns: repeat(4, 1fr); 463 | } 464 | } 465 | 466 | @media (max-width: 1080px) { 467 | :root { 468 | --chat-width: 100%; 469 | --sticker-columns: repeat(6, 1fr); 470 | } 471 | .content-wrapper { 472 | height: 100%; 473 | flex-direction: column; 474 | top: 0; 475 | } 476 | .col-wrapper { 477 | height: auto; 478 | flex-grow: 1; 479 | } 480 | } 481 | 482 | @keyframes scaleIn { 483 | 0% { 484 | transform: scale3d(0.2, 0.2, 1); 485 | } 486 | 100% { 487 | transform: scale3d(1, 1, 1); 488 | } 489 | } 490 | -------------------------------------------------------------------------------- /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, useRef, createRef } from 'react'; 5 | import Linkify from 'linkify-react'; 6 | import axios from 'axios'; 7 | import { 8 | ChatRoom, 9 | DeleteMessageRequest, 10 | DisconnectUserRequest, 11 | SendMessageRequest, 12 | } from 'amazon-ivs-chat-messaging'; 13 | import { uuidv4 } from '../../helpers'; 14 | 15 | import * as config from '../../config'; 16 | 17 | // Components 18 | import VideoPlayer from '../videoPlayer/VideoPlayer'; 19 | import SignIn from './SignIn'; 20 | import StickerPicker from './StickerPicker'; 21 | import RaiseHand from './RaiseHand'; 22 | 23 | // Styles 24 | import './Chat.css'; 25 | 26 | const Chat = () => { 27 | const [showSignIn, setShowSignIn] = useState(true); 28 | const [username, setUsername] = useState(''); 29 | const [moderator, setModerator] = useState(false); 30 | const [message, setMessage] = useState(''); 31 | const [messages, setMessages] = useState([]); 32 | const [chatRoom, setChatRoom] = useState([]); 33 | const [showRaiseHandPopup, setShowRaiseHandPopup] = useState(false); 34 | const [usernameRaisedHand, setUsernameRaisedHand] = useState(null); 35 | const [handRaised, setHandRaised] = useState(false); 36 | const previousRaiseHandUsername = useRef(null); 37 | 38 | const chatRef = createRef(); 39 | const messagesEndRef = createRef(); 40 | 41 | // Fetches a chat token 42 | const tokenProvider = async (selectedUsername, isModerator, avatarUrl) => { 43 | const uuid = uuidv4(); 44 | const permissions = isModerator 45 | ? ['SEND_MESSAGE', 'DELETE_MESSAGE', 'DISCONNECT_USER'] 46 | : ['SEND_MESSAGE']; 47 | 48 | const data = { 49 | arn: config.CHAT_ROOM_ID, 50 | userId: `${selectedUsername}.${uuid}`, 51 | attributes: { 52 | username: `${selectedUsername}`, 53 | avatar: `${avatarUrl.src}`, 54 | }, 55 | capabilities: permissions, 56 | }; 57 | 58 | var token; 59 | try { 60 | const response = await axios.post(`${config.API_URL}/auth`, data); 61 | token = { 62 | token: response.data.token, 63 | sessionExpirationTime: new Date(response.data.sessionExpirationTime), 64 | tokenExpirationTime: new Date(response.data.tokenExpirationTime), 65 | }; 66 | } catch (error) { 67 | console.error('Error:', error); 68 | } 69 | 70 | return token; 71 | }; 72 | 73 | const handleSignIn = (selectedUsername, isModerator, avatarUrl) => { 74 | // Set application state 75 | setUsername(selectedUsername); 76 | setModerator(isModerator); 77 | 78 | // Instantiate a chat room 79 | const room = new ChatRoom({ 80 | regionOrUrl: config.CHAT_REGION, 81 | tokenProvider: () => 82 | tokenProvider(selectedUsername, isModerator, avatarUrl), 83 | }); 84 | setChatRoom(room); 85 | 86 | // Connect to the chat room 87 | room.connect(); 88 | }; 89 | 90 | useEffect(() => { 91 | // If chat room listeners are not available, do not continue 92 | if (!chatRoom.addListener) { 93 | return; 94 | } 95 | 96 | // Hide the sign in modal 97 | setShowSignIn(false); 98 | 99 | const unsubscribeOnConnected = chatRoom.addListener('connect', () => { 100 | // Connected to the chat room. 101 | renderConnect(); 102 | }); 103 | 104 | const unsubscribeOnDisconnected = chatRoom.addListener( 105 | 'disconnect', 106 | (reason) => { 107 | // Disconnected from the chat room. 108 | } 109 | ); 110 | 111 | const unsubscribeOnUserDisconnect = chatRoom.addListener( 112 | 'userDisconnect', 113 | (disconnectUserEvent) => { 114 | /* Example event payload: 115 | * { 116 | * id: "AYk6xKitV4On", 117 | * userId": "R1BLTDN84zEO", 118 | * reason": "Spam", 119 | * sendTime": new Date("2022-10-11T12:56:41.113Z"), 120 | * requestId": "b379050a-2324-497b-9604-575cb5a9c5cd", 121 | * attributes": { UserId: "R1BLTDN84zEO", Reason: "Spam" } 122 | * } 123 | */ 124 | renderDisconnect(disconnectUserEvent.reason); 125 | } 126 | ); 127 | 128 | const unsubscribeOnConnecting = chatRoom.addListener('connecting', () => { 129 | // Connecting to the chat room. 130 | }); 131 | 132 | const unsubscribeOnMessageReceived = chatRoom.addListener( 133 | 'message', 134 | (message) => { 135 | // Received a message 136 | const messageType = message.attributes?.message_type || 'MESSAGE'; 137 | switch (messageType) { 138 | case 'RAISE_HAND': 139 | handleRaiseHand(message); 140 | break; 141 | case 'STICKER': 142 | handleSticker(message); 143 | break; 144 | default: 145 | handleMessage(message); 146 | break; 147 | } 148 | } 149 | ); 150 | 151 | const unsubscribeOnEventReceived = chatRoom.addListener( 152 | 'event', 153 | (event) => { 154 | // Received an event 155 | handleEvent(event); 156 | } 157 | ); 158 | 159 | const unsubscribeOnMessageDeleted = chatRoom.addListener( 160 | 'messageDelete', 161 | (deleteEvent) => { 162 | // Received message delete event 163 | const messageIdToDelete = deleteEvent.messageId; 164 | setMessages((prevState) => { 165 | // Remove message that matches the MessageID to delete 166 | const newState = prevState.filter( 167 | (item) => item.messageId !== messageIdToDelete 168 | ); 169 | return newState; 170 | }); 171 | } 172 | ); 173 | 174 | return () => { 175 | unsubscribeOnConnected(); 176 | unsubscribeOnDisconnected(); 177 | unsubscribeOnUserDisconnect(); 178 | unsubscribeOnConnecting(); 179 | unsubscribeOnMessageReceived(); 180 | unsubscribeOnEventReceived(); 181 | unsubscribeOnMessageDeleted(); 182 | }; 183 | }, [chatRoom]); 184 | 185 | useEffect(() => { 186 | const scrollToBottom = () => { 187 | messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); 188 | }; 189 | scrollToBottom(); 190 | }); 191 | 192 | useEffect(() => { 193 | previousRaiseHandUsername.current = usernameRaisedHand; 194 | }, [usernameRaisedHand]); 195 | 196 | // Handlers 197 | const handleError = (data) => { 198 | const username = ''; 199 | const userId = ''; 200 | const avatar = ''; 201 | const message = `Error ${data.errorCode}: ${data.errorMessage}`; 202 | const messageId = ''; 203 | const timestamp = `${Date.now()}`; 204 | 205 | const newMessage = { 206 | type: 'ERROR', 207 | timestamp, 208 | username, 209 | userId, 210 | avatar, 211 | message, 212 | messageId, 213 | }; 214 | 215 | setMessages((prevState) => { 216 | return [...prevState, newMessage]; 217 | }); 218 | }; 219 | 220 | const handleMessage = (data) => { 221 | const username = data.sender.attributes.username; 222 | const userId = data.sender.userId; 223 | const avatar = data.sender.attributes.avatar; 224 | const message = data.content; 225 | const messageId = data.id; 226 | const timestamp = data.sendTime; 227 | 228 | const newMessage = { 229 | type: 'MESSAGE', 230 | timestamp, 231 | username, 232 | userId, 233 | avatar, 234 | message, 235 | messageId, 236 | }; 237 | 238 | setMessages((prevState) => { 239 | return [...prevState, newMessage]; 240 | }); 241 | }; 242 | 243 | const handleEvent = (event) => { 244 | const eventName = event.eventName; 245 | switch (eventName) { 246 | case 'aws:DELETE_MESSAGE': 247 | // Ignore system delete message events, as they are handled 248 | // by the messageDelete listener on the room. 249 | break; 250 | case 'app:DELETE_BY_USER': 251 | const userIdToDelete = event.attributes.userId; 252 | setMessages((prevState) => { 253 | // Remove message that matches the MessageID to delete 254 | const newState = prevState.filter( 255 | (item) => item.userId !== userIdToDelete 256 | ); 257 | return newState; 258 | }); 259 | break; 260 | default: 261 | console.info('Unhandled event received:', event); 262 | } 263 | }; 264 | 265 | const handleOnClick = () => { 266 | setShowSignIn(true); 267 | }; 268 | 269 | const handleChange = (e) => { 270 | setMessage(e.target.value); 271 | }; 272 | 273 | const handleKeyDown = (e) => { 274 | if (e.key === 'Enter') { 275 | if (message) { 276 | sendMessage(message); 277 | setMessage(''); 278 | } 279 | } 280 | }; 281 | 282 | const deleteMessageByUserId = async (userId) => { 283 | // Send a delete event 284 | try { 285 | const response = await sendEvent({ 286 | eventName: 'app:DELETE_BY_USER', 287 | eventAttributes: { 288 | userId: userId, 289 | }, 290 | }); 291 | return response; 292 | } catch (error) { 293 | return error; 294 | } 295 | }; 296 | 297 | const handleMessageDelete = async (messageId) => { 298 | const request = new DeleteMessageRequest(messageId, 'Reason for deletion'); 299 | try { 300 | await chatRoom.deleteMessage(request); 301 | } catch (error) { 302 | console.error(error); 303 | } 304 | }; 305 | 306 | const handleUserKick = async (userId) => { 307 | const request = new DisconnectUserRequest(userId, 'Kicked by moderator'); 308 | try { 309 | await chatRoom.disconnectUser(request); 310 | await deleteMessageByUserId(userId); 311 | } catch (error) { 312 | console.error(error); 313 | } 314 | }; 315 | 316 | const handleSticker = (data) => { 317 | const username = data.sender.attributes?.username; 318 | const userId = data.sender.userId; 319 | const avatar = data.sender.attributes.avatar; 320 | const message = data.content; 321 | const sticker = data.attributes.sticker_src; 322 | const messageId = data.id; 323 | const timestamp = data.sendTime; 324 | 325 | const newMessage = { 326 | type: 'STICKER', 327 | timestamp, 328 | username, 329 | userId, 330 | avatar, 331 | message, 332 | messageId, 333 | sticker, 334 | }; 335 | 336 | setMessages((prevState) => { 337 | return [...prevState, newMessage]; 338 | }); 339 | }; 340 | 341 | const handleRaiseHand = async (data) => { 342 | const username = data.sender.attributes?.username; 343 | setUsernameRaisedHand(username); 344 | 345 | if (previousRaiseHandUsername.current !== username) { 346 | setShowRaiseHandPopup(true); 347 | } else { 348 | setShowRaiseHandPopup((showRaiseHandPopup) => !showRaiseHandPopup); 349 | } 350 | }; 351 | 352 | const handleStickerSend = async (sticker) => { 353 | const content = `Sticker: ${sticker.name}`; 354 | const attributes = { 355 | message_type: 'STICKER', 356 | sticker_src: `${sticker.src}`, 357 | }; 358 | const request = new SendMessageRequest(content, attributes); 359 | try { 360 | await chatRoom.sendMessage(request); 361 | } catch (error) { 362 | handleError(error); 363 | } 364 | }; 365 | 366 | const handleRaiseHandSend = async () => { 367 | const attributes = { 368 | message_type: 'RAISE_HAND', 369 | }; 370 | 371 | const request = new SendMessageRequest(`[raise hand event]`, attributes); 372 | try { 373 | await chatRoom.sendMessage(request); 374 | setHandRaised((prevState) => !prevState); 375 | } catch (error) { 376 | handleError(error); 377 | } 378 | }; 379 | 380 | const sendMessage = async (message) => { 381 | const content = `${message.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}`; 382 | const request = new SendMessageRequest(content); 383 | try { 384 | await chatRoom.sendMessage(request); 385 | } catch (error) { 386 | handleError(error); 387 | } 388 | }; 389 | 390 | const sendEvent = async (data) => { 391 | const formattedData = { 392 | arn: config.CHAT_ROOM_ID, 393 | eventName: `${data.eventName}`, 394 | eventAttributes: data.eventAttributes, 395 | }; 396 | 397 | try { 398 | const response = await axios.post( 399 | `${config.API_URL}/event`, 400 | formattedData 401 | ); 402 | console.info('SendEvent Success:', response.data); 403 | return response; 404 | } catch (error) { 405 | console.error('SendEvent Error:', error); 406 | return error; 407 | } 408 | }; 409 | 410 | // Renderers 411 | const renderErrorMessage = (errorMessage) => { 412 | return ( 413 |
414 |

{errorMessage.message}

415 |
416 | ); 417 | }; 418 | 419 | const renderSuccessMessage = (successMessage) => { 420 | return ( 421 |
422 |

{successMessage.message}

423 |
424 | ); 425 | }; 426 | 427 | const renderChatLineActions = (message) => { 428 | return ( 429 | <> 430 | 447 | 467 | 468 | ); 469 | }; 470 | 471 | const renderStickerMessage = (message) => ( 472 |
473 |
474 | {`Avatar 479 |

480 | {message.username} 481 |

482 | {`sticker`} 483 |
484 | {moderator ? renderChatLineActions(message) : ''} 485 |
486 | ); 487 | 488 | const renderMessage = (message) => { 489 | return ( 490 |
491 |
492 | {`Avatar 497 |

498 | {message.username} 499 | 507 | {message.message} 508 | 509 |

510 |
511 | {moderator ? renderChatLineActions(message) : ''} 512 |
513 | ); 514 | }; 515 | 516 | const renderMessages = () => { 517 | return messages.map((message) => { 518 | switch (message.type) { 519 | case 'ERROR': 520 | const errorMessage = renderErrorMessage(message); 521 | return errorMessage; 522 | case 'SUCCESS': 523 | const successMessage = renderSuccessMessage(message); 524 | return successMessage; 525 | case 'STICKER': 526 | const stickerMessage = renderStickerMessage(message); 527 | return stickerMessage; 528 | case 'MESSAGE': 529 | const textMessage = renderMessage(message); 530 | return textMessage; 531 | default: 532 | console.info('Received unsupported message:', message); 533 | return <>; 534 | } 535 | }); 536 | }; 537 | 538 | const renderDisconnect = (reason) => { 539 | const error = { 540 | type: 'ERROR', 541 | timestamp: `${Date.now()}`, 542 | username: '', 543 | userId: '', 544 | avatar: '', 545 | message: `Connection closed. Reason: ${reason}`, 546 | }; 547 | setMessages((prevState) => { 548 | return [...prevState, error]; 549 | }); 550 | }; 551 | 552 | const renderConnect = () => { 553 | const status = { 554 | type: 'SUCCESS', 555 | timestamp: `${Date.now()}`, 556 | username: '', 557 | userId: '', 558 | avatar: '', 559 | message: `Connected to the chat room.`, 560 | }; 561 | setMessages((prevState) => { 562 | return [...prevState, status]; 563 | }); 564 | }; 565 | 566 | const isChatConnected = () => { 567 | const chatState = chatRoom.state; 568 | return chatState === 'connected'; 569 | }; 570 | 571 | return ( 572 | <> 573 |
574 |

Amazon IVS Chat Web Demo

575 |
576 |
577 |
578 | 583 |
584 |
585 |
586 | {renderMessages()} 587 |
588 |
589 |
590 | 605 | {isChatConnected() && ( 606 | 607 | )} 608 | {isChatConnected() && ( 609 | 613 | )} 614 | {!username && ( 615 |
616 | 622 |
623 | )} 624 |
625 |
626 |
627 |
628 | {showSignIn && } 629 |
630 | 631 | ); 632 | }; 633 | 634 | export default Chat; 635 | -------------------------------------------------------------------------------- /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 | *, 6 | *::before, 7 | *::after { 8 | box-sizing: border-box; 9 | } 10 | ul[class], 11 | ol[class] { 12 | padding: 0; 13 | } 14 | body, 15 | h1, 16 | h2, 17 | h3, 18 | h4, 19 | p, 20 | ul[class], 21 | ol[class], 22 | figure, 23 | blockquote, 24 | dl, 25 | dd { 26 | margin: 0; 27 | } 28 | html { 29 | scroll-behavior: smooth; 30 | } 31 | body { 32 | min-height: 100vh; 33 | text-rendering: optimizeSpeed; 34 | line-height: 1.5; 35 | } 36 | ul[class], 37 | ol[class] { 38 | list-style: none; 39 | } 40 | a:not([class]) { 41 | text-decoration-skip-ink: auto; 42 | } 43 | img { 44 | max-width: 100%; 45 | display: block; 46 | } 47 | article > * + * { 48 | margin-top: 1em; 49 | } 50 | input, 51 | button, 52 | textarea, 53 | select { 54 | font: inherit; 55 | } 56 | @media (prefers-reduced-motion: reduce) { 57 | * { 58 | animation-duration: 0.01ms !important; 59 | animation-iteration-count: 1 !important; 60 | transition-duration: 0.01ms !important; 61 | scroll-behavior: auto !important; 62 | } 63 | } 64 | 65 | /* --------------------------------------------------------------- */ 66 | /* Variables */ 67 | :root { 68 | /* Fonts */ 69 | --font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 70 | Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif, 71 | 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; 72 | --font-serif: 'Iowan Old Style', 'Apple Garamond', Baskerville, 73 | 'Times New Roman', 'Droid Serif', Times, 'Source Serif Pro', serif, 74 | 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; 75 | --font-mono: Consolas, monaco, 'Ubuntu Mono', 'Liberation Mono', 'Courier New', 76 | Courier, monospace; 77 | 78 | /* Color tokens */ 79 | --color--primary: #2b44ff; 80 | --color--secondary: #2026a2; 81 | --color--tertiary: #8d9ca7; 82 | --color--positive: #0fd70b; 83 | --color--destructive: #fd2222; 84 | 85 | /* Sizing */ 86 | --section-max-width: 800px; 87 | --input-height: 42px; 88 | --radius: 10px; 89 | --radius-small: 4px; 90 | --header-height: 50px; 91 | --btn-floating-size: 56px; 92 | --btn-floating-icon-size: 40px; 93 | --modal-min-width: 480px; 94 | 95 | /* Light theme color assignment */ 96 | --color-text-base: #000; 97 | --color-text-alt: #4b5358; 98 | --color-text-inverted: #fff; 99 | --color-text-hint: #8d9ca7; 100 | --color-text-primary: var(--color--primary); 101 | --color-text-secondary: var(--color--secondary); 102 | --color-text-tertiary: var(--color--tertiary); 103 | --color-text-positive: var(--color--positive); 104 | --color-text-destructive: var(--color--destructive); 105 | --color-bg-body: #fff; 106 | --color-bg-base: #fff; 107 | --color-bg-alt: #f1f2f3; 108 | --color-bg-alt-2: #e1e2e3; 109 | --color-bg-inverted: #000; 110 | --color-bg-primary: var(--color--primary); 111 | --color-bg-secondary: var(--color--secondary); 112 | --color-bg-tertiary: var(--color--tertiary); 113 | --color-bg-positive: var(--color--positive); 114 | --color-bg-destructive: var(--color--destructive); 115 | --color-bg-header: var(--color-bg-body); 116 | --color-bg-modal: var(--color-bg-body); 117 | --color-bg-modal-overlay: var(--color--secondary); 118 | --color-bg-chat: var(--color-bg-body); 119 | --color-bg-chat-bubble: var(--color-bg-alt); 120 | --color-bg-player: var(--color-bg-alt); 121 | --color-bg-placeholder: var(--color-bg-alt); 122 | --color-bg-button: var(--color-bg-alt); 123 | --color-bg-button-active: var(); 124 | --color-bg-button-focus: var(--color-bg-base); 125 | --color-bg-button-hover: var(--color-bg-alt-2); 126 | --color-bg-button-inverted: var(); 127 | --color-bg-button-inverted-active: var(); 128 | --color-bg-button-inverted-focus: var(); 129 | --color-bg-button-inverted-hover: var(); 130 | --color-bg-button-primary-default: var(--color--primary); 131 | --color-bg-button-primary-active: var(); 132 | --color-bg-button-primary-hover: var(); 133 | --color-bg-button-secondary-default: var(--color-bg-alt); 134 | --color-bg-button-secondary-active: var(); 135 | --color-bg-button-secondary-hover: var(); 136 | --color-bg-button-floating: var(--color--primary); 137 | --color-bg-button-floating-active: var(); 138 | --color-bg-button-floating-focus: var(); 139 | --color-bg-button-floating-hover: var(--color--secondary); 140 | --color-bg-input: var(--color-bg-alt); 141 | --color-bg-input-focus: var(); 142 | --color-bg-notice-success: var(--color--positive); 143 | --color-bg-notice-error: var(--color--destructive); 144 | --color-border-base: #dfe5e9; 145 | --color-border-error: var(--color--destructive); 146 | 147 | --grid-2-columns: 1fr 1fr; 148 | --grid-3-columns: 1fr 1fr 1fr; 149 | --grid-4-columns: 1fr 1fr 1fr 1fr; 150 | --grid-trio-columns: 1fr 3fr 1fr 1fr; 151 | } 152 | 153 | @media (max-width: 480px) { 154 | /* Smaller Screens */ 155 | :root { 156 | --section-max-width: 800px; 157 | --input-height: 42px; 158 | --radius: 10px; 159 | --radius-small: 4px; 160 | --header-height: 50px; 161 | --btn-floating-size: 56px; 162 | --btn-floating-icon-size: 40px; 163 | --modal-min-width: 320px; 164 | 165 | --grid-2-columns: 1fr; 166 | --grid-3-columns: 1fr; 167 | --grid-4-columns: 1fr; 168 | --grid-trio-columns: 1fr 1fr 1fr 1fr; 169 | } 170 | } 171 | 172 | @media (min-width: 480px) and (max-width: 767px) { 173 | /* Small Screens */ 174 | :root { 175 | --section-max-width: 800px; 176 | --input-height: 42px; 177 | --radius: 10px; 178 | --radius-small: 4px; 179 | --header-height: 50px; 180 | --btn-floating-size: 56px; 181 | --btn-floating-icon-size: 40px; 182 | 183 | --grid-2-columns: 1fr 1fr; 184 | --grid-3-columns: 1fr 1fr 1fr; 185 | --grid-4-columns: 1fr 1fr; 186 | --grid-trio-columns: 1fr 2fr 1fr 1fr; 187 | } 188 | } 189 | 190 | /* --------------------------------------------------------------- */ 191 | 192 | /* Style */ 193 | html { 194 | font-size: 62.5%; 195 | } 196 | 197 | html, 198 | body { 199 | width: 100%; 200 | height: 100%; 201 | margin: 0; 202 | padding: 0; 203 | color: var(--color-text-base); 204 | background: var(--color-bg-base); 205 | line-height: 1.5; 206 | } 207 | 208 | body { 209 | font-family: var(--font-sans); 210 | font-size: 1.6rem; 211 | } 212 | 213 | ::selection { 214 | background: var(--color--primary); 215 | color: var(--color-text-inverted); 216 | } 217 | 218 | a { 219 | text-decoration: none; 220 | } 221 | 222 | /* Section */ 223 | section { 224 | max-width: var(--section-max-width); 225 | margin: 0 auto; 226 | } 227 | 228 | h1 { 229 | font-size: 3.6rem; 230 | } 231 | 232 | h2 { 233 | font-size: 2.4rem; 234 | } 235 | 236 | h3 { 237 | font-size: 1.8rem; 238 | font-weight: 300; 239 | } 240 | 241 | ul { 242 | margin: 0; 243 | padding: 1rem 0; 244 | list-style-position: inside; 245 | } 246 | 247 | ul li { 248 | margin: 0; 249 | } 250 | 251 | em { 252 | font-weight: 300; 253 | font-size: 1.4rem; 254 | } 255 | 256 | .formatted-text h1 { 257 | margin-bottom: 1rem; 258 | } 259 | .formatted-text h2 { 260 | margin-bottom: 1rem; 261 | } 262 | .formatted-text h3 { 263 | margin-bottom: 0.5rem; 264 | } 265 | .formatted-text ul { 266 | margin-bottom: 0.5rem; 267 | } 268 | .formatted-text p { 269 | margin-bottom: 0.5rem; 270 | } 271 | .formatted-text p:last-child { 272 | margin-bottom: 0; 273 | } 274 | 275 | /* Utility - Text */ 276 | .color-base { 277 | color: var(--color-text-base); 278 | } 279 | .color-alt { 280 | color: var(--color-text-alt); 281 | } 282 | .color-inverted { 283 | color: var(--color-text-inverted); 284 | } 285 | .color-hint { 286 | color: var(--color-text-hint); 287 | } 288 | .color-primary { 289 | color: var(--color-text-primary); 290 | } 291 | .color-secondary { 292 | color: var(--color-text-secondary); 293 | } 294 | .color-tertiary { 295 | color: var(--color-text-tertiary); 296 | } 297 | .color-positive { 298 | color: var(--color-text-positive); 299 | } 300 | .color-destructive { 301 | color: var(--color-text-destructive); 302 | } 303 | 304 | /* Utility - Background */ 305 | .bg-body { 306 | background-color: var(--color-bg-body); 307 | } 308 | .bg-base { 309 | background-color: var(--color-bg-base); 310 | } 311 | .bg-alt { 312 | background-color: var(--color-bg-alt); 313 | } 314 | .bg-alt-2 { 315 | background-color: var(--color-bg-alt-2); 316 | } 317 | .bg-inverted { 318 | background-color: var(--color-bg-inverted); 319 | } 320 | .bg-primary { 321 | background-color: var(--color-bg-primary); 322 | } 323 | .bg-secondary { 324 | background-color: var(--color-bg-secondary); 325 | } 326 | .bg-tertiary { 327 | background-color: var(--color-bg-tertiary); 328 | } 329 | .bg-positive { 330 | background-color: var(--color-bg-positive); 331 | } 332 | .bg-destructive { 333 | background-color: var(--color-bg-destructive); 334 | } 335 | 336 | /* Utility - Radius */ 337 | .br-all { 338 | border-radius: var(--radius); 339 | } 340 | 341 | /* Utility - Padding */ 342 | .pd-0 { 343 | padding: 0; 344 | } 345 | .pd-05 { 346 | padding: 0.5rem; 347 | } 348 | .pd-1 { 349 | padding: 1rem; 350 | } 351 | .pd-15 { 352 | padding: 1.5rem; 353 | } 354 | .pd-2 { 355 | padding: 2rem; 356 | } 357 | .pd-25 { 358 | padding: 2.5rem; 359 | } 360 | .pd-3 { 361 | padding: 3rem; 362 | } 363 | .pd-35 { 364 | padding: 3.5rem; 365 | } 366 | .pd-4 { 367 | padding: 4rem; 368 | } 369 | .pd-5 { 370 | padding: 5rem; 371 | } 372 | 373 | .pd-x-0 { 374 | padding-left: 0; 375 | padding-right: 0; 376 | } 377 | .pd-x-05 { 378 | padding-left: 0.5rem; 379 | padding-right: 0.5rem; 380 | } 381 | .pd-x-1 { 382 | padding-left: 1rem; 383 | padding-right: 1rem; 384 | } 385 | .pd-x-15 { 386 | padding-left: 1.5rem; 387 | padding-right: 1.5rem; 388 | } 389 | .pd-x-2 { 390 | padding-left: 2rem; 391 | padding-right: 2rem; 392 | } 393 | .pd-x-25 { 394 | padding-left: 2.5rem; 395 | padding-right: 2.5rem; 396 | } 397 | .pd-x-3 { 398 | padding-left: 3rem; 399 | padding-right: 3rem; 400 | } 401 | .pd-x-35 { 402 | padding-left: 3.5rem; 403 | padding-right: 3rem; 404 | } 405 | .pd-x-4 { 406 | padding-left: 4rem; 407 | padding-right: 4rem; 408 | } 409 | .pd-x-5 { 410 | padding-left: 5rem; 411 | padding-right: 5rem; 412 | } 413 | 414 | .pd-y-0 { 415 | padding-top: 0; 416 | padding-bottom: 0; 417 | } 418 | .pd-y-05 { 419 | padding-top: 0.5rem; 420 | padding-bottom: 0.5rem; 421 | } 422 | .pd-y-1 { 423 | padding-top: 1rem; 424 | padding-bottom: 1rem; 425 | } 426 | .pd-y-15 { 427 | padding-top: 1.5rem; 428 | padding-bottom: 1.5rem; 429 | } 430 | .pd-y-2 { 431 | padding-top: 2rem; 432 | padding-bottom: 2rem; 433 | } 434 | .pd-y-25 { 435 | padding-top: 2.5rem; 436 | padding-bottom: 2.5rem; 437 | } 438 | .pd-y-3 { 439 | padding-top: 3rem; 440 | padding-bottom: 3rem; 441 | } 442 | .pd-y-35 { 443 | padding-top: 3.5rem; 444 | padding-bottom: 3rem; 445 | } 446 | .pd-y-4 { 447 | padding-top: 4rem; 448 | padding-bottom: 4rem; 449 | } 450 | .pd-y-5 { 451 | padding-top: 5rem; 452 | padding-bottom: 5rem; 453 | } 454 | 455 | .pd-t-0 { 456 | padding-top: 0; 457 | } 458 | .pd-t-05 { 459 | padding-top: 0.5rem; 460 | } 461 | .pd-t-1 { 462 | padding-top: 1rem; 463 | } 464 | .pd-t-15 { 465 | padding-top: 1.5rem; 466 | } 467 | .pd-t-2 { 468 | padding-top: 2rem; 469 | } 470 | .pd-t-25 { 471 | padding-top: 2.5rem; 472 | } 473 | .pd-t-3 { 474 | padding-top: 3rem; 475 | } 476 | .pd-t-35 { 477 | padding-top: 3.5rem; 478 | } 479 | .pd-t-4 { 480 | padding-top: 4rem; 481 | } 482 | .pd-t-5 { 483 | padding-top: 5rem; 484 | } 485 | 486 | .pd-r-0 { 487 | padding-right: 0; 488 | } 489 | .pd-r-05 { 490 | padding-right: 0.5rem; 491 | } 492 | .pd-r-1 { 493 | padding-right: 1rem; 494 | } 495 | .pd-r-15 { 496 | padding-right: 1.5rem; 497 | } 498 | .pd-r-2 { 499 | padding-right: 2rem; 500 | } 501 | .pd-r-25 { 502 | padding-right: 2.5rem; 503 | } 504 | .pd-r-3 { 505 | padding-right: 3rem; 506 | } 507 | .pd-r-35 { 508 | padding-right: 3.5rem; 509 | } 510 | .pd-r-4 { 511 | padding-right: 4rem; 512 | } 513 | .pd-r-5 { 514 | padding-right: 5rem; 515 | } 516 | 517 | .pd-b-0 { 518 | padding-bottom: 0; 519 | } 520 | .pd-b-05 { 521 | padding-bottom: 0.5rem; 522 | } 523 | .pd-b-1 { 524 | padding-bottom: 1rem; 525 | } 526 | .pd-b-15 { 527 | padding-bottom: 1.5rem; 528 | } 529 | .pd-b-2 { 530 | padding-bottom: 2rem; 531 | } 532 | .pd-b-25 { 533 | padding-bottom: 2.5rem; 534 | } 535 | .pd-b-3 { 536 | padding-bottom: 3rem; 537 | } 538 | .pd-b-35 { 539 | padding-bottom: 3.5rem; 540 | } 541 | .pd-b-4 { 542 | padding-bottom: 4rem; 543 | } 544 | .pd-b-5 { 545 | padding-bottom: 5rem; 546 | } 547 | 548 | .pd-l-0 { 549 | padding-left: 0; 550 | } 551 | .pd-l-05 { 552 | padding-left: 0.5rem; 553 | } 554 | .pd-l-1 { 555 | padding-left: 1rem; 556 | } 557 | .pd-l-15 { 558 | padding-left: 1.5rem; 559 | } 560 | .pd-l-2 { 561 | padding-left: 2rem; 562 | } 563 | .pd-l-25 { 564 | padding-left: 2.5rem; 565 | } 566 | .pd-l-3 { 567 | padding-left: 3rem; 568 | } 569 | .pd-l-35 { 570 | padding-left: 3.5rem; 571 | } 572 | .pd-l-4 { 573 | padding-left: 4rem; 574 | } 575 | .pd-l-5 { 576 | padding-left: 5rem; 577 | } 578 | 579 | /* Utility - Margin */ 580 | .mg-0 { 581 | margin: 0; 582 | } 583 | .mg-05 { 584 | margin: 0.5rem; 585 | } 586 | .mg-1 { 587 | margin: 1rem; 588 | } 589 | .mg-15 { 590 | margin: 1.5rem; 591 | } 592 | .mg-2 { 593 | margin: 2rem; 594 | } 595 | .mg-25 { 596 | margin: 2.5rem; 597 | } 598 | .mg-3 { 599 | margin: 3rem; 600 | } 601 | .mg-35 { 602 | margin: 3.5rem; 603 | } 604 | .mg-4 { 605 | margin: 4rem; 606 | } 607 | .mg-5 { 608 | margin: 5rem; 609 | } 610 | 611 | .mg-x-0 { 612 | margin-left: 0; 613 | margin-right: 0; 614 | } 615 | .mg-x-05 { 616 | margin-left: 0.5rem; 617 | margin-right: 0.5rem; 618 | } 619 | .mg-x-1 { 620 | margin-left: 1rem; 621 | margin-right: 1rem; 622 | } 623 | .mg-x-15 { 624 | margin-left: 1.5rem; 625 | margin-right: 1.5rem; 626 | } 627 | .mg-x-2 { 628 | margin-left: 2rem; 629 | margin-right: 2rem; 630 | } 631 | .mg-x-25 { 632 | margin-left: 2.5rem; 633 | margin-right: 2.5rem; 634 | } 635 | .mg-x-3 { 636 | margin-left: 3rem; 637 | margin-right: 3rem; 638 | } 639 | .mg-x-35 { 640 | margin-left: 3.5rem; 641 | margin-right: 3rem; 642 | } 643 | .mg-x-4 { 644 | margin-left: 4rem; 645 | margin-right: 4rem; 646 | } 647 | .mg-x-5 { 648 | margin-left: 5rem; 649 | margin-right: 5rem; 650 | } 651 | 652 | .mg-y-0 { 653 | margin-top: 0; 654 | margin-bottom: 0; 655 | } 656 | .mg-y-05 { 657 | margin-top: 0.5rem; 658 | margin-bottom: 0.5rem; 659 | } 660 | .mg-y-1 { 661 | margin-top: 1rem; 662 | margin-bottom: 1rem; 663 | } 664 | .mg-y-15 { 665 | margin-top: 1.5rem; 666 | margin-bottom: 1.5rem; 667 | } 668 | .mg-y-2 { 669 | margin-top: 2rem; 670 | margin-bottom: 2rem; 671 | } 672 | .mg-y-25 { 673 | margin-top: 2.5rem; 674 | margin-bottom: 2.5rem; 675 | } 676 | .mg-y-3 { 677 | margin-top: 3rem; 678 | margin-bottom: 3rem; 679 | } 680 | .mg-y-35 { 681 | margin-top: 3.5rem; 682 | margin-bottom: 3rem; 683 | } 684 | .mg-y-4 { 685 | margin-top: 4rem; 686 | margin-bottom: 4rem; 687 | } 688 | .mg-y-5 { 689 | margin-top: 5rem; 690 | margin-bottom: 5rem; 691 | } 692 | 693 | .mg-t-0 { 694 | margin-top: 0; 695 | } 696 | .mg-t-05 { 697 | margin-top: 0.5rem; 698 | } 699 | .mg-t-1 { 700 | margin-top: 1rem; 701 | } 702 | .mg-t-15 { 703 | margin-top: 1.5rem; 704 | } 705 | .mg-t-2 { 706 | margin-top: 2rem; 707 | } 708 | .mg-t-25 { 709 | margin-top: 2.5rem; 710 | } 711 | .mg-t-3 { 712 | margin-top: 3rem; 713 | } 714 | .mg-t-35 { 715 | margin-top: 3.5rem; 716 | } 717 | .mg-t-4 { 718 | margin-top: 4rem; 719 | } 720 | .mg-t-5 { 721 | margin-top: 5rem; 722 | } 723 | 724 | .mg-r-0 { 725 | margin-right: 0; 726 | } 727 | .mg-r-05 { 728 | margin-right: 0.5rem; 729 | } 730 | .mg-r-1 { 731 | margin-right: 1rem; 732 | } 733 | .mg-r-15 { 734 | margin-right: 1.5rem; 735 | } 736 | .mg-r-2 { 737 | margin-right: 2rem; 738 | } 739 | .mg-r-25 { 740 | margin-right: 2.5rem; 741 | } 742 | .mg-r-3 { 743 | margin-right: 3rem; 744 | } 745 | .mg-r-35 { 746 | margin-right: 3.5rem; 747 | } 748 | .mg-r-4 { 749 | margin-right: 4rem; 750 | } 751 | .mg-r-5 { 752 | margin-right: 5rem; 753 | } 754 | 755 | .mg-b-0 { 756 | margin-bottom: 0; 757 | } 758 | .mg-b-05 { 759 | margin-bottom: 0.5rem; 760 | } 761 | .mg-b-1 { 762 | margin-bottom: 1rem; 763 | } 764 | .mg-b-15 { 765 | margin-bottom: 1.5rem; 766 | } 767 | .mg-b-2 { 768 | margin-bottom: 2rem; 769 | } 770 | .mg-b-25 { 771 | margin-bottom: 2.5rem; 772 | } 773 | .mg-b-3 { 774 | margin-bottom: 3rem; 775 | } 776 | .mg-b-35 { 777 | margin-bottom: 3.5rem; 778 | } 779 | .mg-b-4 { 780 | margin-bottom: 4rem; 781 | } 782 | .mg-b-5 { 783 | margin-bottom: 5rem; 784 | } 785 | 786 | .mg-l-0 { 787 | margin-left: 0; 788 | } 789 | .mg-l-05 { 790 | margin-left: 0.5rem; 791 | } 792 | .mg-l-1 { 793 | margin-left: 1rem; 794 | } 795 | .mg-l-15 { 796 | margin-left: 1.5rem; 797 | } 798 | .mg-l-2 { 799 | margin-left: 2rem; 800 | } 801 | .mg-l-25 { 802 | margin-left: 2.5rem; 803 | } 804 | .mg-l-3 { 805 | margin-left: 3rem; 806 | } 807 | .mg-l-35 { 808 | margin-left: 3.5rem; 809 | } 810 | .mg-l-4 { 811 | margin-left: 4rem; 812 | } 813 | .mg-l-5 { 814 | margin-left: 5rem; 815 | } 816 | 817 | /* Utility - Flex */ 818 | .fl { 819 | display: flex; 820 | } 821 | .fl-inline { 822 | display: inline-flex; 823 | } 824 | 825 | .fl-row { 826 | flex-direction: row; 827 | } /* Default */ 828 | .fl-row-rev { 829 | flex-direction: row-reverse; 830 | } 831 | .fl-col { 832 | flex-direction: column; 833 | } 834 | .fl-col-rev { 835 | flex-direction: column-reverse; 836 | } 837 | 838 | .fl-nowrap { 839 | flex-wrap: nowrap; 840 | } /* Default */ 841 | .fl-wrap { 842 | flex-wrap: wrap; 843 | } 844 | .fl-wrap-rev { 845 | flex-wrap: wrap-reverse; 846 | } 847 | 848 | .fl-j-start { 849 | justify-content: flex-start; 850 | } /* Default */ 851 | .fl-j-end { 852 | justify-content: flex-end; 853 | } 854 | .fl-j-center { 855 | justify-content: center; 856 | } 857 | .fl-j-around { 858 | justify-content: space-around; 859 | } 860 | .fl-j-between { 861 | justify-content: space-between; 862 | } 863 | 864 | .fl-a-stretch { 865 | align-items: stretch; 866 | } /* Default */ 867 | .fl-a-start { 868 | align-items: flex-start; 869 | } 870 | .fl-a-center { 871 | align-items: center; 872 | } 873 | .fl-a-end { 874 | align-items: flex-end; 875 | } 876 | .fl-a-baseline { 877 | align-items: baseline; 878 | } 879 | 880 | .fl-grow-0 { 881 | flex-grow: 0; 882 | } /* Default */ 883 | .fl-grow-1 { 884 | flex-grow: 1; 885 | } 886 | 887 | .fl-shrink-1 { 888 | flex-shrink: 1; 889 | } /* Default */ 890 | .fl-shrink-0 { 891 | flex-shrink: 0; 892 | } 893 | 894 | .fl-b-auto { 895 | flex-basis: auto; 896 | } /* Default */ 897 | .fl-b-0 { 898 | flex-basis: 0; 899 | } 900 | 901 | .fl-a-auto { 902 | align-self: auto; 903 | } /* Default */ 904 | .fl-a-start { 905 | align-self: flex-start; 906 | } 907 | .fl-a-center { 908 | align-self: center; 909 | } 910 | .fl-a-end { 911 | align-self: flex-end; 912 | } 913 | .fl-a-stretch { 914 | align-self: stretch; 915 | } 916 | .fl-a-baseline { 917 | align-self: baseline; 918 | } 919 | 920 | /* Utility - Position */ 921 | .pos-absolute { 922 | position: absolute !important; 923 | } 924 | .pos-fixed { 925 | position: fixed !important; 926 | } 927 | .pos-relative { 928 | position: relative !important; 929 | } 930 | .top-0 { 931 | top: 0 !important; 932 | } 933 | .bottom-0 { 934 | bottom: 0 !important; 935 | } 936 | 937 | /* Utility - Width/Height */ 938 | .full-width { 939 | width: 100%; 940 | } 941 | .full-height { 942 | height: 100%; 943 | } 944 | 945 | /* Blur */ 946 | .blur { 947 | filter: blur(70px); 948 | } 949 | 950 | /* Overflow */ 951 | .no-overflow { 952 | overflow: hidden; 953 | } 954 | 955 | /* Grid */ 956 | .grid { 957 | width: 100%; 958 | display: grid; 959 | grid-gap: 1rem; 960 | } 961 | 962 | .grid.grid--2 { 963 | grid-template-columns: 1fr 1fr; 964 | } 965 | 966 | .grid.grid--3 { 967 | grid-template-columns: 1fr 1fr 1fr; 968 | } 969 | 970 | .grid.grid--4 { 971 | grid-template-columns: 1fr 1fr 1fr 1fr; 972 | } 973 | 974 | .grid.grid--trio { 975 | grid-template-columns: 1fr 3fr 1fr 1fr; 976 | } 977 | 978 | /* Responsive Grid */ 979 | .grid--responsive.grid--2 { 980 | grid-template-columns: var(--grid-2-columns); 981 | } 982 | 983 | .grid--responsive.grid--3 { 984 | grid-template-columns: var(--grid-3-columns); 985 | } 986 | 987 | .grid--responsive.grid--4 { 988 | grid-template-columns: var(--grid-4-columns); 989 | } 990 | 991 | .grid--responsive.grid--trio { 992 | grid-template-columns: var(--grid-trio-columns); 993 | } 994 | 995 | /* Header bar */ 996 | header { 997 | height: var(--header-height); 998 | box-shadow: 0 1px 0 0 var(--color-border-base); 999 | position: sticky; 1000 | top: 0; 1001 | left: 0; 1002 | right: 0; 1003 | padding: 0 2rem; 1004 | background: var(--color-bg-header); 1005 | z-index: 10; 1006 | } 1007 | 1008 | header h1 { 1009 | font-size: 16px; 1010 | font-weight: 800; 1011 | line-height: var(--header-height); 1012 | } 1013 | 1014 | /* Modal */ 1015 | .modal { 1016 | width: 100%; 1017 | height: 100vh; 1018 | position: absolute; 1019 | top: 0; 1020 | left: 0; 1021 | display: grid; 1022 | place-items: center; 1023 | z-index: 2; 1024 | } 1025 | 1026 | .modal__el { 1027 | max-width: 570px; 1028 | min-width: var(--modal-min-width); 1029 | position: relative; 1030 | z-index: 2; 1031 | background: var(--color-bg-modal); 1032 | display: flex; 1033 | flex-direction: column; 1034 | padding: 3rem; 1035 | } 1036 | 1037 | .modal__overlay { 1038 | position: absolute; 1039 | top: 0; 1040 | right: 0; 1041 | bottom: 0; 1042 | left: 0; 1043 | background: var(--color-bg-modal-overlay); 1044 | opacity: 0.9; 1045 | } 1046 | 1047 | /* Code */ 1048 | code, 1049 | pre, 1050 | kbd, 1051 | samp { 1052 | font-family: var(--font-mono); 1053 | } 1054 | 1055 | .codeblock { 1056 | padding: 1rem; 1057 | color: var(--color-text-alt); 1058 | background: var(--color-bg-placeholder); 1059 | border-radius: var(--radius); 1060 | } 1061 | 1062 | /* Placeholder blocks */ 1063 | .placeholder { 1064 | min-height: 180px; 1065 | background: var(--color-bg-placeholder); 1066 | border-radius: var(--radius); 1067 | } 1068 | 1069 | /* Aspect ratio */ 1070 | .aspect-169 { 1071 | padding-top: 56.25%; 1072 | height: 0; 1073 | overflow: hidden; 1074 | } 1075 | 1076 | .player { 1077 | background: var(--color-bg-player); 1078 | } 1079 | 1080 | /* Buttons & Forms */ 1081 | form { 1082 | display: flex; 1083 | flex-direction: column; 1084 | align-items: flex-start; 1085 | } 1086 | 1087 | fieldset { 1088 | width: 100%; 1089 | border: 0; 1090 | padding: 0; 1091 | margin: 0; 1092 | display: flex; 1093 | flex-direction: column; 1094 | } 1095 | 1096 | fieldset input, 1097 | fieldset textarea, 1098 | fieldset select, 1099 | fieldset button { 1100 | width: 100%; 1101 | margin-bottom: 1rem; 1102 | } 1103 | 1104 | label, 1105 | .label { 1106 | font-weight: 500; 1107 | } 1108 | 1109 | label span, 1110 | .label span { 1111 | font-weight: 200; 1112 | } 1113 | 1114 | button { 1115 | border: 2px solid transparent; 1116 | outline: none; 1117 | appearance: none; 1118 | cursor: pointer; 1119 | -webkit-appearance: none; 1120 | border-radius: var(--radius-small); 1121 | } 1122 | 1123 | input, 1124 | select, 1125 | textarea { 1126 | border: 2px solid transparent; 1127 | outline: none; 1128 | appearance: none; 1129 | resize: none; 1130 | -webkit-appearance: none; 1131 | padding: 1rem; 1132 | background: var(--color-bg-input); 1133 | border-radius: var(--radius-small); 1134 | } 1135 | 1136 | .btn, 1137 | button, 1138 | select, 1139 | input[type='text'], 1140 | input[type='password'], 1141 | input[type='submit'], 1142 | input[type='reset'], 1143 | input[type='button'] { 1144 | height: var(--input-height); 1145 | } 1146 | 1147 | input:focus, 1148 | textarea:focus, 1149 | .btn:focus, 1150 | .btn:active { 1151 | border: 2px solid var(--color--primary); 1152 | } 1153 | 1154 | input[type='checkbox'] { 1155 | font: inherit; 1156 | color: currentColor; 1157 | width: 1.2rem; 1158 | height: 1.2rem; 1159 | border: 0.2rem solid currentColor; 1160 | border-radius: 0.2rem; 1161 | transform: translateY(0.175em); 1162 | display: grid; 1163 | place-content: center; 1164 | } 1165 | 1166 | input[type='checkbox']:focus { 1167 | box-shadow: 0 0 0 2px currentColor; 1168 | } 1169 | 1170 | input[type='checkbox']:hover, 1171 | label:hover { 1172 | cursor: pointer; 1173 | } 1174 | 1175 | input[type='checkbox']::before { 1176 | content: ''; 1177 | width: 1.2rem; 1178 | height: 1.2rem; 1179 | transform: scale(0); 1180 | transition: 120ms transform ease-in-out; 1181 | transform-origin: center; 1182 | clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); 1183 | background: var(--color--primary); 1184 | } 1185 | 1186 | input[type='checkbox']:checked::before { 1187 | transform: scale(1); 1188 | } 1189 | 1190 | input[type='checkbox']:checked { 1191 | content: '\2713'; 1192 | text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.2); 1193 | font-size: 15px; 1194 | color: #f3f3f3; 1195 | text-align: center; 1196 | line-height: 15px; 1197 | } 1198 | 1199 | input[type='checkbox']:checked:focus { 1200 | box-shadow: 0 0 0 2px var(--color--primary); 1201 | } 1202 | 1203 | select { 1204 | padding: 0 20px 0 10px; 1205 | position: relative; 1206 | } 1207 | 1208 | select:focus, 1209 | select:active { 1210 | border: 2px solid var(--color--primary); 1211 | } 1212 | 1213 | .btn.rounded, 1214 | input.rounded { 1215 | border-radius: var(--input-height); 1216 | } 1217 | 1218 | .btn { 1219 | font-weight: 500; 1220 | background: var(--color-bg-button); 1221 | } 1222 | 1223 | .btn--primary { 1224 | background: var(--color-bg-button-primary-default); 1225 | color: var(--color-text-inverted); 1226 | } 1227 | 1228 | .btn--primary:hover, 1229 | .btn--primary:focus { 1230 | background: var(--color--secondary); 1231 | } 1232 | 1233 | .btn--secondary { 1234 | background: var(--color-bg-button-secondary-default); 1235 | color: var(--color-text-base); 1236 | } 1237 | 1238 | .btn--destruct { 1239 | background: var(--color--destructive); 1240 | color: var(--color-text-inverted); 1241 | } 1242 | 1243 | .btn--confirm { 1244 | background: var(--color--positive); 1245 | } 1246 | 1247 | .btn--floating { 1248 | width: var(--btn-floating-size); 1249 | height: var(--btn-floating-size); 1250 | background: var(--color-bg-button-floating); 1251 | border-radius: var(--btn-floating-size); 1252 | color: var(--color-text-inverted); 1253 | display: flex; 1254 | align-items: center; 1255 | position: absolute; 1256 | bottom: 2rem; 1257 | right: 2rem; 1258 | } 1259 | 1260 | .btn--floating svg { 1261 | width: var(--btn-floating-icon-size); 1262 | height: var(--btn-floating-icon-size); 1263 | fill: var(--color-text-inverted); 1264 | } 1265 | 1266 | .btn--floating:hover, 1267 | .btn--floating:focus { 1268 | background: var(--color-bg-button-floating-hover); 1269 | } 1270 | 1271 | .btn--fixed { 1272 | position: fixed; 1273 | } 1274 | 1275 | /* Interactive */ 1276 | .interactive { 1277 | cursor: pointer; 1278 | border: 2px solid transparent; 1279 | display: flex; 1280 | padding: 1rem; 1281 | flex-direction: column; 1282 | color: var(--color-text-base); 1283 | overflow: hidden; 1284 | } 1285 | 1286 | .interactive strong, 1287 | .interactive span { 1288 | text-overflow: ellipsis; 1289 | white-space: nowrap; 1290 | overflow: hidden; 1291 | } 1292 | 1293 | .interactive:focus, 1294 | .interactive:hover { 1295 | background: var(--color-bg-button); 1296 | color: var(--color-bg-button-primary-default); 1297 | } 1298 | 1299 | .interactive:focus { 1300 | border: 2px solid var(--color--primary); 1301 | outline: none; 1302 | } 1303 | 1304 | .interactive--active, 1305 | .interactive--active:hover, 1306 | .interactive--active:focus { 1307 | background: var(--color-bg-button-primary-default); 1308 | color: var(--color-text-inverted); 1309 | } 1310 | 1311 | /* Notices */ 1312 | .notice { 1313 | border-radius: var(--radius-small); 1314 | position: absolute; 1315 | top: 1.5rem; 1316 | right: 1.5rem; 1317 | } 1318 | 1319 | .notice__content { 1320 | display: flex; 1321 | padding: 1.5rem 2rem; 1322 | font-weight: 600; 1323 | } 1324 | 1325 | .notice--success { 1326 | background: var(--color-bg-notice-success); 1327 | } 1328 | 1329 | .notice--error { 1330 | background: var(--color-bg-notice-error); 1331 | color: var(--color-text-inverted); 1332 | } 1333 | 1334 | .notice__icon { 1335 | margin-right: 1rem; 1336 | } 1337 | 1338 | /* Icons */ 1339 | .icon { 1340 | fill: var(--color-text-base); 1341 | } 1342 | 1343 | .icon--inverted { 1344 | fill: var(--color-text-inverted); 1345 | } 1346 | 1347 | .icon--success { 1348 | fill: var(--color--positive); 1349 | } 1350 | 1351 | .icon--error { 1352 | fill: var(--color--destructive); 1353 | } 1354 | 1355 | .icon--14 { 1356 | width: 14px; 1357 | height: 14px; 1358 | } 1359 | 1360 | .icon--24 { 1361 | width: 24px; 1362 | height: 24px; 1363 | } 1364 | 1365 | .icon--36 { 1366 | width: 36px; 1367 | height: 36px; 1368 | } 1369 | 1370 | .icon--48 { 1371 | width: 48px; 1372 | height: 48px; 1373 | } 1374 | --------------------------------------------------------------------------------