├── .gitignore ├── .graphqlconfig.yml ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── amplify ├── .config │ └── project-config.json └── backend │ ├── api │ └── thisorthat │ │ ├── parameters.json │ │ ├── resolvers │ │ ├── Mutation.upVote.req.vtl │ │ └── Mutation.upVote.res.vtl │ │ ├── schema.graphql │ │ ├── stacks │ │ └── CustomResources.json │ │ └── transform.conf.json │ ├── auth │ └── thisorthatd21dc504 │ │ ├── parameters.json │ │ └── thisorthatd21dc504-cloudformation-template.yml │ ├── backend-config.json │ └── storage │ └── images │ ├── parameters.json │ ├── s3-cloudformation-template.json │ └── storage-params.json ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── savvy.png ├── src ├── About.js ├── Button.js ├── Candidates.js ├── CreatePoll.js ├── Footer.js ├── Header.js ├── Poll.js ├── Polls.js ├── Router.js ├── actionTypes.js ├── assets │ ├── logo.png │ ├── main.css │ ├── tailwind.css │ ├── voteblue.svg │ └── votepink.svg ├── gql │ ├── mutations.js │ ├── queries.js │ └── subscriptions.js ├── graphql │ ├── mutations.js │ ├── queries.js │ └── subscriptions.js ├── index.css ├── index.js ├── loading.svg ├── logo.svg ├── serviceWorker.js ├── setupTests.js └── utils │ ├── localStorageInfo.js │ └── slugify.js ├── tailwind.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | amplify/team-provider-info.json 26 | 27 | #amplify 28 | amplify/\#current-cloud-backend 29 | amplify/.config/local-* 30 | amplify/logs 31 | amplify/mock-data 32 | amplify/backend/amplify-meta.json 33 | amplify/backend/awscloudformation 34 | amplify/backend/.temp 35 | build/ 36 | dist/ 37 | node_modules/ 38 | aws-exports.js 39 | awsconfiguration.json 40 | amplifyconfiguration.json 41 | amplify-build-config.json 42 | amplify-gradle-config.json 43 | amplifytools.xcconfig 44 | .secret-* -------------------------------------------------------------------------------- /.graphqlconfig.yml: -------------------------------------------------------------------------------- 1 | projects: 2 | thisorthat: 3 | schemaPath: amplify/backend/api/thisorthat/build/schema.graphql 4 | includes: 5 | - src/graphql/**/*.js 6 | excludes: 7 | - ./amplify/** 8 | extensions: 9 | amplify: 10 | codeGenTarget: javascript 11 | generatedFileName: "" 12 | docsFilePath: src/graphql 13 | region: us-east-1 14 | apiId: null 15 | maxDepth: 2 16 | extensions: 17 | amplify: 18 | version: 3 19 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "amplify/.config": true, 4 | "amplify/**/*-parameters.json": true, 5 | "amplify/**/amplify.state": true, 6 | "amplify/**/transform.conf.json": true, 7 | "amplify/#current-cloud-backend": true, 8 | "amplify/backend/amplify-meta.json": true, 9 | "amplify/backend/awscloudformation": true 10 | } 11 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Savvy 3 |
4 |
5 |

6 | Savvy - Build better products with customer feedback 7 |

8 |

9 | Made with ♥ and AWS Amplify by 10 | liyasthomas 11 | 12 |

13 |
14 | 15 | --- 16 | 17 | ## About the app 18 | 19 | Savvy is a scalable serverless customer feedback tool for developers, indie hackers, entrepreneurs and startups - built with AWS Amplify, AWS AppSync, and Amazon DynamoDB on [AWS Amplify Hackathon by Hashnode](https://townhall.hashnode.com/announcing-aws-amplify-hackathon-on-hashnode). 20 | 21 | ### Features 22 | 23 | - Capture feedbacks from your customers and public in **one organized place.** 24 | 25 | Give voice to your community, get valuable suggestions and prioritize what they need the most. 26 | 27 | - Text and image polls. 28 | 29 | - Custom labels for options. 30 | 31 | - Self-hostable + scalable + serverless. 32 | 33 | One click deployment to AWA Amplify. 34 | 35 | - **Your own domain.** 36 | 37 | When using AWS Amplify hosting service, you can use your own domain and get a free TLS certificate to keep it secure. 38 | 39 | - Shareable link for individual feature requests. 40 | 41 | Invite your customers to create, vote and prioritize feedback. 42 | 43 | - Dark mode 44 | 45 | - Disqus comments. 46 | 47 | Share ideas, vote and discuss. 48 | 49 | - Open source. 50 | 51 | ### Demo 52 | 53 | [Savvy](https://master.dup21zsuytyqn.amplifyapp.com/) 54 | 55 | ### Get started 56 | 57 | **How Savvy works** 58 | 59 | Three simple steps to understand the workflow. 60 | 61 | 1. Setup 62 | 63 | Use one-click deploy to AWS Amplify or follow detailed instructions on GitHub repository. Update Discus short name. Customize with your own logo, colors and text. 64 | 65 | 2. Collect 66 | 67 | Invite your customers to your new Savvy site. They'll be able to suggest new ideas, submit feature requests or report issues they have with your product. Publish your Savvy site on any hosting service to accept public feedbacks. 68 | 69 | 3. Deliver 70 | 71 | Keep your customers in the loop by responding to their suggestions. Customers will be notified of any new action on their topics of interest. Prioritize feature requests and feedbacks internally. 72 | 73 | ### Deploy 74 | 75 | [![amplifybutton](https://oneclick.amplifyapp.com/button.svg)](https://console.aws.amazon.com/amplify/home#/deploy?repo=https://github.com/liyasthomas/savvy) 76 | 77 | ### Develop 78 | 79 | 1. First install and configure the Amplify CLI. 80 | 81 | ```sh 82 | $ npm install -g @aws-amplify/cli 83 | $ amplify configure 84 | ``` 85 | 86 | 2. Clone the repo, install dependencies 87 | 88 | ```sh 89 | $ git clone https://github.com/liyasthomas/savvy 90 | $ cd savvy 91 | $ npm install 92 | ``` 93 | 94 | 3. Initialize the app 95 | 96 | ```sh 97 | $ amplify init 98 | 99 | ? Enter a name for the environment: (your preferred env name) 100 | ? Choose your default editor: (your preferred editor) 101 | ? Do you want to use an AWS profile? Yes 102 | ? Please choose the profile you want to use: your-profile-name 103 | 104 | ? Do you want to configure Lambda Triggers for Cognito? No 105 | ``` 106 | 107 | 4. Deploy the back end 108 | 109 | ```sh 110 | $ amplify push --y 111 | ``` 112 | 113 | 5. Run the app 114 | 115 | ```sh 116 | $ npm start 117 | ``` 118 | 119 | ### **License** 120 | 121 | This project is licensed under the [MIT License](https://opensource.org/licenses/MIT) - see the [`LICENSE`](LICENSE) file for details. 122 | -------------------------------------------------------------------------------- /amplify/.config/project-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "this-or-that", 3 | "version": "3.0", 4 | "frontend": "javascript", 5 | "javascript": { 6 | "framework": "react", 7 | "config": { 8 | "SourceDir": "src", 9 | "DistributionDir": "build", 10 | "BuildCommand": "npm run-script build", 11 | "StartCommand": "npm run-script start" 12 | } 13 | }, 14 | "providers": [ 15 | "awscloudformation" 16 | ] 17 | } -------------------------------------------------------------------------------- /amplify/backend/api/thisorthat/parameters.json: -------------------------------------------------------------------------------- 1 | { 2 | "AppSyncApiName": "thisorthat", 3 | "DynamoDBBillingMode": "PAY_PER_REQUEST", 4 | "DynamoDBEnableServerSideEncryption": "false" 5 | } -------------------------------------------------------------------------------- /amplify/backend/api/thisorthat/resolvers/Mutation.upVote.req.vtl: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2018-05-29", 3 | "operation": "UpdateItem", 4 | "key" : { 5 | "id" : $util.dynamodb.toDynamoDBJson($context.arguments.id) 6 | }, 7 | "update": { 8 | "expression" : "set #upvotes = #upvotes + :updateValue", 9 | "expressionNames" : { 10 | "#upvotes" : "upvotes" 11 | }, 12 | "expressionValues" : { 13 | ":updateValue" : { "N" : 1 } 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /amplify/backend/api/thisorthat/resolvers/Mutation.upVote.res.vtl: -------------------------------------------------------------------------------- 1 | $util.quiet($ctx.result.put("clientId", "$context.arguments.clientId")) 2 | $util.toJson($ctx.result) -------------------------------------------------------------------------------- /amplify/backend/api/thisorthat/schema.graphql: -------------------------------------------------------------------------------- 1 | type Poll @model 2 | @key(name: "byItemType", fields: ["itemType", "createdAt"], queryField: "itemsByType") 3 | @auth(rules: [ 4 | { allow: public, operations: [create, read] } 5 | ]) { 6 | id: ID! 7 | name: String! 8 | type: PollType! 9 | candidates: [Candidate] @connection 10 | itemType: String 11 | createdAt: String 12 | } 13 | 14 | type Candidate @model 15 | @auth(rules: [ 16 | { allow: public, operations: [create, read] } 17 | ]) { 18 | id: ID! 19 | pollCandidatesId: ID 20 | image: String 21 | name: String 22 | upvotes: Int 23 | } 24 | 25 | enum PollType { 26 | image 27 | text 28 | } 29 | 30 | type VoteType { 31 | id: ID 32 | clientId: ID 33 | } 34 | 35 | type Mutation { 36 | upVote(id: ID, clientId: ID): VoteType 37 | } 38 | 39 | type Subscription { 40 | onUpdateByID(id: ID!): VoteType 41 | @aws_subscribe(mutations: ["upVote"]) 42 | } -------------------------------------------------------------------------------- /amplify/backend/api/thisorthat/stacks/CustomResources.json: -------------------------------------------------------------------------------- 1 | { 2 | "AWSTemplateFormatVersion": "2010-09-09", 3 | "Description": "An auto-generated nested stack.", 4 | "Metadata": {}, 5 | "Parameters": { 6 | "AppSyncApiId": { 7 | "Type": "String", 8 | "Description": "The id of the AppSync API associated with this project." 9 | }, 10 | "AppSyncApiName": { 11 | "Type": "String", 12 | "Description": "The name of the AppSync API", 13 | "Default": "AppSyncSimpleTransform" 14 | }, 15 | "env": { 16 | "Type": "String", 17 | "Description": "The environment name. e.g. Dev, Test, or Production", 18 | "Default": "NONE" 19 | }, 20 | "S3DeploymentBucket": { 21 | "Type": "String", 22 | "Description": "The S3 bucket containing all deployment assets for the project." 23 | }, 24 | "S3DeploymentRootKey": { 25 | "Type": "String", 26 | "Description": "An S3 key relative to the S3DeploymentBucket that points to the root\nof the deployment directory." 27 | } 28 | }, 29 | "Resources": { 30 | "UpvoteResolver": { 31 | "Type": "AWS::AppSync::Resolver", 32 | "Properties": { 33 | "ApiId": { 34 | "Ref": "AppSyncApiId" 35 | }, 36 | "DataSourceName": "CandidateTable", 37 | "TypeName": "Mutation", 38 | "FieldName": "upVote", 39 | "RequestMappingTemplateS3Location": { 40 | "Fn::Sub": [ 41 | "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/Mutation.upVote.req.vtl", 42 | { 43 | "S3DeploymentBucket": { 44 | "Ref": "S3DeploymentBucket" 45 | }, 46 | "S3DeploymentRootKey": { 47 | "Ref": "S3DeploymentRootKey" 48 | } 49 | } 50 | ] 51 | }, 52 | "ResponseMappingTemplateS3Location": { 53 | "Fn::Sub": [ 54 | "s3://${S3DeploymentBucket}/${S3DeploymentRootKey}/resolvers/Mutation.upVote.res.vtl", 55 | { 56 | "S3DeploymentBucket": { 57 | "Ref": "S3DeploymentBucket" 58 | }, 59 | "S3DeploymentRootKey": { 60 | "Ref": "S3DeploymentRootKey" 61 | } 62 | } 63 | ] 64 | } 65 | } 66 | } 67 | }, 68 | "Conditions": { 69 | "HasEnvironmentParameter": { 70 | "Fn::Not": [ 71 | { 72 | "Fn::Equals": [ 73 | { 74 | "Ref": "env" 75 | }, 76 | "NONE" 77 | ] 78 | } 79 | ] 80 | }, 81 | "AlwaysFalse": { 82 | "Fn::Equals": [ 83 | "true", 84 | "false" 85 | ] 86 | } 87 | }, 88 | "Outputs": { 89 | "EmptyOutput": { 90 | "Description": "An empty output. You may delete this if you have at least one resource above.", 91 | "Value": "" 92 | } 93 | } 94 | } -------------------------------------------------------------------------------- /amplify/backend/api/thisorthat/transform.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": 5, 3 | "ElasticsearchWarning": true 4 | } -------------------------------------------------------------------------------- /amplify/backend/auth/thisorthatd21dc504/parameters.json: -------------------------------------------------------------------------------- 1 | { 2 | "identityPoolName": "thisorthatd21dc504_identitypool_d21dc504", 3 | "allowUnauthenticatedIdentities": true, 4 | "resourceNameTruncated": "thisord21dc504", 5 | "userPoolName": "thisorthatd21dc504_userpool_d21dc504", 6 | "autoVerifiedAttributes": [ 7 | "email" 8 | ], 9 | "mfaConfiguration": "OFF", 10 | "mfaTypes": [ 11 | "SMS Text Message" 12 | ], 13 | "smsAuthenticationMessage": "Your authentication code is {####}", 14 | "smsVerificationMessage": "Your verification code is {####}", 15 | "emailVerificationSubject": "Your verification code", 16 | "emailVerificationMessage": "Your verification code is {####}", 17 | "defaultPasswordPolicy": false, 18 | "passwordPolicyMinLength": 8, 19 | "passwordPolicyCharacters": [], 20 | "requiredAttributes": [ 21 | "email" 22 | ], 23 | "userpoolClientGenerateSecret": true, 24 | "userpoolClientRefreshTokenValidity": 30, 25 | "userpoolClientWriteAttributes": [ 26 | "email" 27 | ], 28 | "userpoolClientReadAttributes": [ 29 | "email" 30 | ], 31 | "userpoolClientLambdaRole": "thisord21dc504_userpoolclient_lambda_role", 32 | "userpoolClientSetAttributes": false, 33 | "resourceName": "thisorthatd21dc504", 34 | "authSelections": "identityPoolAndUserPool", 35 | "authRoleArn": { 36 | "Fn::GetAtt": [ 37 | "AuthRole", 38 | "Arn" 39 | ] 40 | }, 41 | "unauthRoleArn": { 42 | "Fn::GetAtt": [ 43 | "UnauthRole", 44 | "Arn" 45 | ] 46 | }, 47 | "useDefault": "manual", 48 | "userPoolGroupList": [], 49 | "dependsOn": [], 50 | "thirdPartyAuth": false, 51 | "userPoolGroups": false, 52 | "adminQueries": false, 53 | "triggers": "{}", 54 | "hostedUI": false, 55 | "parentStack": { 56 | "Ref": "AWS::StackId" 57 | }, 58 | "permissions": [] 59 | } -------------------------------------------------------------------------------- /amplify/backend/auth/thisorthatd21dc504/thisorthatd21dc504-cloudformation-template.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: 2010-09-09 2 | 3 | Parameters: 4 | env: 5 | Type: String 6 | authRoleArn: 7 | Type: String 8 | unauthRoleArn: 9 | Type: String 10 | 11 | 12 | 13 | 14 | identityPoolName: 15 | Type: String 16 | 17 | allowUnauthenticatedIdentities: 18 | Type: String 19 | 20 | resourceNameTruncated: 21 | Type: String 22 | 23 | userPoolName: 24 | Type: String 25 | 26 | autoVerifiedAttributes: 27 | Type: CommaDelimitedList 28 | 29 | mfaConfiguration: 30 | Type: String 31 | 32 | mfaTypes: 33 | Type: CommaDelimitedList 34 | 35 | smsAuthenticationMessage: 36 | Type: String 37 | 38 | smsVerificationMessage: 39 | Type: String 40 | 41 | emailVerificationSubject: 42 | Type: String 43 | 44 | emailVerificationMessage: 45 | Type: String 46 | 47 | defaultPasswordPolicy: 48 | Type: String 49 | 50 | passwordPolicyMinLength: 51 | Type: Number 52 | 53 | passwordPolicyCharacters: 54 | Type: CommaDelimitedList 55 | 56 | requiredAttributes: 57 | Type: CommaDelimitedList 58 | 59 | userpoolClientGenerateSecret: 60 | Type: String 61 | 62 | userpoolClientRefreshTokenValidity: 63 | Type: Number 64 | 65 | userpoolClientWriteAttributes: 66 | Type: CommaDelimitedList 67 | 68 | userpoolClientReadAttributes: 69 | Type: CommaDelimitedList 70 | 71 | userpoolClientLambdaRole: 72 | Type: String 73 | 74 | userpoolClientSetAttributes: 75 | Type: String 76 | 77 | resourceName: 78 | Type: String 79 | 80 | authSelections: 81 | Type: String 82 | 83 | useDefault: 84 | Type: String 85 | 86 | userPoolGroupList: 87 | Type: CommaDelimitedList 88 | 89 | dependsOn: 90 | Type: CommaDelimitedList 91 | 92 | thirdPartyAuth: 93 | Type: String 94 | 95 | userPoolGroups: 96 | Type: String 97 | 98 | adminQueries: 99 | Type: String 100 | 101 | triggers: 102 | Type: String 103 | 104 | hostedUI: 105 | Type: String 106 | 107 | parentStack: 108 | Type: String 109 | 110 | permissions: 111 | Type: CommaDelimitedList 112 | 113 | Conditions: 114 | ShouldNotCreateEnvResources: !Equals [ !Ref env, NONE ] 115 | 116 | Resources: 117 | 118 | 119 | # BEGIN SNS ROLE RESOURCE 120 | SNSRole: 121 | # Created to allow the UserPool SMS Config to publish via the Simple Notification Service during MFA Process 122 | Type: AWS::IAM::Role 123 | Properties: 124 | RoleName: !If [ShouldNotCreateEnvResources, 'thisord21dc504_sns-role', !Join ['',[ 'sns', !Select [3, !Split ['-', !Ref 'AWS::StackName']], '-', !Ref env]]] 125 | AssumeRolePolicyDocument: 126 | Version: "2012-10-17" 127 | Statement: 128 | - Sid: "" 129 | Effect: "Allow" 130 | Principal: 131 | Service: "cognito-idp.amazonaws.com" 132 | Action: 133 | - "sts:AssumeRole" 134 | Condition: 135 | StringEquals: 136 | sts:ExternalId: thisord21dc504_role_external_id 137 | Policies: 138 | - 139 | PolicyName: thisord21dc504-sns-policy 140 | PolicyDocument: 141 | Version: "2012-10-17" 142 | Statement: 143 | - 144 | Effect: "Allow" 145 | Action: 146 | - "sns:Publish" 147 | Resource: "*" 148 | # BEGIN USER POOL RESOURCES 149 | UserPool: 150 | # Created upon user selection 151 | # Depends on SNS Role for Arn if MFA is enabled 152 | Type: AWS::Cognito::UserPool 153 | UpdateReplacePolicy: Retain 154 | Properties: 155 | UserPoolName: !If [ShouldNotCreateEnvResources, !Ref userPoolName, !Join ['',[!Ref userPoolName, '-', !Ref env]]] 156 | 157 | Schema: 158 | 159 | - 160 | Name: email 161 | Required: true 162 | Mutable: true 163 | 164 | 165 | 166 | 167 | AutoVerifiedAttributes: !Ref autoVerifiedAttributes 168 | 169 | 170 | EmailVerificationMessage: !Ref emailVerificationMessage 171 | EmailVerificationSubject: !Ref emailVerificationSubject 172 | 173 | Policies: 174 | PasswordPolicy: 175 | MinimumLength: !Ref passwordPolicyMinLength 176 | RequireLowercase: false 177 | RequireNumbers: false 178 | RequireSymbols: false 179 | RequireUppercase: false 180 | 181 | MfaConfiguration: !Ref mfaConfiguration 182 | SmsVerificationMessage: !Ref smsVerificationMessage 183 | SmsConfiguration: 184 | SnsCallerArn: !GetAtt SNSRole.Arn 185 | ExternalId: thisord21dc504_role_external_id 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | # Updating lambda role with permissions to Cognito 198 | 199 | 200 | UserPoolClientWeb: 201 | # Created provide application access to user pool 202 | # Depends on UserPool for ID reference 203 | Type: "AWS::Cognito::UserPoolClient" 204 | Properties: 205 | ClientName: thisord21dc504_app_clientWeb 206 | 207 | RefreshTokenValidity: !Ref userpoolClientRefreshTokenValidity 208 | UserPoolId: !Ref UserPool 209 | DependsOn: UserPool 210 | UserPoolClient: 211 | # Created provide application access to user pool 212 | # Depends on UserPool for ID reference 213 | Type: "AWS::Cognito::UserPoolClient" 214 | Properties: 215 | ClientName: thisord21dc504_app_client 216 | 217 | GenerateSecret: !Ref userpoolClientGenerateSecret 218 | RefreshTokenValidity: !Ref userpoolClientRefreshTokenValidity 219 | UserPoolId: !Ref UserPool 220 | DependsOn: UserPool 221 | # BEGIN USER POOL LAMBDA RESOURCES 222 | UserPoolClientRole: 223 | # Created to execute Lambda which gets userpool app client config values 224 | Type: 'AWS::IAM::Role' 225 | Properties: 226 | RoleName: !If [ShouldNotCreateEnvResources, !Ref userpoolClientLambdaRole, !Join ['',['upClientLambdaRole', !Select [3, !Split ['-', !Ref 'AWS::StackName']], '-', !Ref env]]] 227 | AssumeRolePolicyDocument: 228 | Version: '2012-10-17' 229 | Statement: 230 | - Effect: Allow 231 | Principal: 232 | Service: 233 | - lambda.amazonaws.com 234 | Action: 235 | - 'sts:AssumeRole' 236 | DependsOn: UserPoolClient 237 | UserPoolClientLambda: 238 | # Lambda which gets userpool app client config values 239 | # Depends on UserPool for id 240 | # Depends on UserPoolClientRole for role ARN 241 | Type: 'AWS::Lambda::Function' 242 | Properties: 243 | Code: 244 | ZipFile: !Join 245 | - |+ 246 | - - 'const response = require(''cfn-response'');' 247 | - 'const aws = require(''aws-sdk'');' 248 | - 'const identity = new aws.CognitoIdentityServiceProvider();' 249 | - 'exports.handler = (event, context, callback) => {' 250 | - ' if (event.RequestType == ''Delete'') { ' 251 | - ' response.send(event, context, response.SUCCESS, {})' 252 | - ' }' 253 | - ' if (event.RequestType == ''Update'' || event.RequestType == ''Create'') {' 254 | - ' const params = {' 255 | - ' ClientId: event.ResourceProperties.clientId,' 256 | - ' UserPoolId: event.ResourceProperties.userpoolId' 257 | - ' };' 258 | - ' identity.describeUserPoolClient(params).promise()' 259 | - ' .then((res) => {' 260 | - ' response.send(event, context, response.SUCCESS, {''appSecret'': res.UserPoolClient.ClientSecret});' 261 | - ' })' 262 | - ' .catch((err) => {' 263 | - ' response.send(event, context, response.FAILED, {err});' 264 | - ' });' 265 | - ' }' 266 | - '};' 267 | Handler: index.handler 268 | Runtime: nodejs10.x 269 | Timeout: '300' 270 | Role: !GetAtt 271 | - UserPoolClientRole 272 | - Arn 273 | DependsOn: UserPoolClientRole 274 | UserPoolClientLambdaPolicy: 275 | # Sets userpool policy for the role that executes the Userpool Client Lambda 276 | # Depends on UserPool for Arn 277 | # Marked as depending on UserPoolClientRole for easier to understand CFN sequencing 278 | Type: 'AWS::IAM::Policy' 279 | Properties: 280 | PolicyName: thisord21dc504_userpoolclient_lambda_iam_policy 281 | Roles: 282 | - !Ref UserPoolClientRole 283 | PolicyDocument: 284 | Version: '2012-10-17' 285 | Statement: 286 | - Effect: Allow 287 | Action: 288 | - 'cognito-idp:DescribeUserPoolClient' 289 | Resource: !GetAtt UserPool.Arn 290 | DependsOn: UserPoolClientLambda 291 | UserPoolClientLogPolicy: 292 | # Sets log policy for the role that executes the Userpool Client Lambda 293 | # Depends on UserPool for Arn 294 | # Marked as depending on UserPoolClientLambdaPolicy for easier to understand CFN sequencing 295 | Type: 'AWS::IAM::Policy' 296 | Properties: 297 | PolicyName: thisord21dc504_userpoolclient_lambda_log_policy 298 | Roles: 299 | - !Ref UserPoolClientRole 300 | PolicyDocument: 301 | Version: 2012-10-17 302 | Statement: 303 | - Effect: Allow 304 | Action: 305 | - 'logs:CreateLogGroup' 306 | - 'logs:CreateLogStream' 307 | - 'logs:PutLogEvents' 308 | Resource: !Sub 309 | - arn:aws:logs:${region}:${account}:log-group:/aws/lambda/${lambda}:log-stream:* 310 | - { region: !Ref "AWS::Region", account: !Ref "AWS::AccountId", lambda: !Ref UserPoolClientLambda} 311 | DependsOn: UserPoolClientLambdaPolicy 312 | UserPoolClientInputs: 313 | # Values passed to Userpool client Lambda 314 | # Depends on UserPool for Id 315 | # Depends on UserPoolClient for Id 316 | # Marked as depending on UserPoolClientLambdaPolicy for easier to understand CFN sequencing 317 | Type: 'Custom::LambdaCallout' 318 | Properties: 319 | ServiceToken: !GetAtt UserPoolClientLambda.Arn 320 | clientId: !Ref UserPoolClient 321 | userpoolId: !Ref UserPool 322 | DependsOn: UserPoolClientLogPolicy 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | # BEGIN IDENTITY POOL RESOURCES 331 | 332 | 333 | IdentityPool: 334 | # Always created 335 | Type: AWS::Cognito::IdentityPool 336 | Properties: 337 | IdentityPoolName: !If [ShouldNotCreateEnvResources, 'thisorthatd21dc504_identitypool_d21dc504', !Join ['',['thisorthatd21dc504_identitypool_d21dc504', '__', !Ref env]]] 338 | 339 | CognitoIdentityProviders: 340 | - ClientId: !Ref UserPoolClient 341 | ProviderName: !Sub 342 | - cognito-idp.${region}.amazonaws.com/${client} 343 | - { region: !Ref "AWS::Region", client: !Ref UserPool} 344 | - ClientId: !Ref UserPoolClientWeb 345 | ProviderName: !Sub 346 | - cognito-idp.${region}.amazonaws.com/${client} 347 | - { region: !Ref "AWS::Region", client: !Ref UserPool} 348 | 349 | AllowUnauthenticatedIdentities: !Ref allowUnauthenticatedIdentities 350 | 351 | 352 | DependsOn: UserPoolClientInputs 353 | 354 | 355 | IdentityPoolRoleMap: 356 | # Created to map Auth and Unauth roles to the identity pool 357 | # Depends on Identity Pool for ID ref 358 | Type: AWS::Cognito::IdentityPoolRoleAttachment 359 | Properties: 360 | IdentityPoolId: !Ref IdentityPool 361 | Roles: 362 | unauthenticated: !Ref unauthRoleArn 363 | authenticated: !Ref authRoleArn 364 | DependsOn: IdentityPool 365 | 366 | 367 | Outputs : 368 | 369 | IdentityPoolId: 370 | Value: !Ref 'IdentityPool' 371 | Description: Id for the identity pool 372 | IdentityPoolName: 373 | Value: !GetAtt IdentityPool.Name 374 | 375 | 376 | 377 | 378 | UserPoolId: 379 | Value: !Ref 'UserPool' 380 | Description: Id for the user pool 381 | UserPoolName: 382 | Value: !Ref userPoolName 383 | AppClientIDWeb: 384 | Value: !Ref 'UserPoolClientWeb' 385 | Description: The user pool app client id for web 386 | AppClientID: 387 | Value: !Ref 'UserPoolClient' 388 | Description: The user pool app client id 389 | AppClientSecret: 390 | Value: !GetAtt UserPoolClientInputs.appSecret 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | -------------------------------------------------------------------------------- /amplify/backend/backend-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "auth": { 3 | "thisorthatd21dc504": { 4 | "service": "Cognito", 5 | "providerPlugin": "awscloudformation", 6 | "dependsOn": [], 7 | "customAuth": false 8 | } 9 | }, 10 | "storage": { 11 | "images": { 12 | "service": "S3", 13 | "providerPlugin": "awscloudformation" 14 | } 15 | }, 16 | "api": { 17 | "thisorthat": { 18 | "service": "AppSync", 19 | "providerPlugin": "awscloudformation", 20 | "output": { 21 | "authConfig": { 22 | "additionalAuthenticationProviders": [], 23 | "defaultAuthentication": { 24 | "authenticationType": "API_KEY", 25 | "apiKeyConfig": { 26 | "description": "public", 27 | "apiKeyExpirationDays": "365" 28 | } 29 | } 30 | } 31 | } 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /amplify/backend/storage/images/parameters.json: -------------------------------------------------------------------------------- 1 | { 2 | "bucketName": "thisorthatimagebucket", 3 | "authPolicyName": "s3_amplify_3dfb3b5a", 4 | "unauthPolicyName": "s3_amplify_3dfb3b5a", 5 | "authRoleName": { 6 | "Ref": "AuthRoleName" 7 | }, 8 | "unauthRoleName": { 9 | "Ref": "UnauthRoleName" 10 | }, 11 | "selectedGuestPermissions": [ 12 | "s3:PutObject", 13 | "s3:GetObject", 14 | "s3:ListBucket", 15 | "s3:DeleteObject" 16 | ], 17 | "selectedAuthenticatedPermissions": [ 18 | "s3:PutObject", 19 | "s3:GetObject", 20 | "s3:ListBucket", 21 | "s3:DeleteObject" 22 | ], 23 | "s3PermissionsAuthenticatedPublic": "s3:PutObject,s3:GetObject,s3:DeleteObject", 24 | "s3PublicPolicy": "Public_policy_d0807258", 25 | "s3PermissionsAuthenticatedUploads": "s3:PutObject", 26 | "s3UploadsPolicy": "Uploads_policy_d0807258", 27 | "s3PermissionsAuthenticatedProtected": "s3:PutObject,s3:GetObject,s3:DeleteObject", 28 | "s3ProtectedPolicy": "Protected_policy_d8d388bb", 29 | "s3PermissionsAuthenticatedPrivate": "s3:PutObject,s3:GetObject,s3:DeleteObject", 30 | "s3PrivatePolicy": "Private_policy_d8d388bb", 31 | "AuthenticatedAllowList": "ALLOW", 32 | "s3ReadPolicy": "read_policy_d0807258", 33 | "s3PermissionsGuestPublic": "s3:PutObject,s3:GetObject,s3:DeleteObject", 34 | "s3PermissionsGuestUploads": "s3:PutObject", 35 | "GuestAllowList": "ALLOW", 36 | "triggerFunction": "NONE" 37 | } -------------------------------------------------------------------------------- /amplify/backend/storage/images/s3-cloudformation-template.json: -------------------------------------------------------------------------------- 1 | { 2 | "AWSTemplateFormatVersion": "2010-09-09", 3 | "Description": "S3 resource stack creation using Amplify CLI", 4 | "Parameters": { 5 | "bucketName": { 6 | "Type": "String" 7 | }, 8 | "authPolicyName": { 9 | "Type": "String" 10 | }, 11 | "unauthPolicyName": { 12 | "Type": "String" 13 | }, 14 | "authRoleName": { 15 | "Type": "String" 16 | }, 17 | "unauthRoleName": { 18 | "Type": "String" 19 | }, 20 | "s3PublicPolicy": { 21 | "Type": "String", 22 | "Default" : "NONE" 23 | }, 24 | "s3PrivatePolicy": { 25 | "Type": "String", 26 | "Default" : "NONE" 27 | }, 28 | "s3ProtectedPolicy": { 29 | "Type": "String", 30 | "Default" : "NONE" 31 | }, 32 | "s3UploadsPolicy": { 33 | "Type": "String", 34 | "Default" : "NONE" 35 | }, 36 | "s3ReadPolicy": { 37 | "Type": "String", 38 | "Default" : "NONE" 39 | }, 40 | "s3PermissionsAuthenticatedPublic": { 41 | "Type": "String", 42 | "Default" : "DISALLOW" 43 | }, 44 | "s3PermissionsAuthenticatedProtected": { 45 | "Type": "String", 46 | "Default" : "DISALLOW" 47 | }, 48 | "s3PermissionsAuthenticatedPrivate": { 49 | "Type": "String", 50 | "Default" : "DISALLOW" 51 | }, 52 | "s3PermissionsAuthenticatedUploads": { 53 | "Type": "String", 54 | "Default" : "DISALLOW" 55 | }, 56 | "s3PermissionsGuestPublic": { 57 | "Type": "String", 58 | "Default" : "DISALLOW" 59 | }, 60 | "s3PermissionsGuestUploads": { 61 | "Type": "String", 62 | "Default" : "DISALLOW" }, 63 | "AuthenticatedAllowList": { 64 | "Type": "String", 65 | "Default" : "DISALLOW" 66 | }, 67 | "GuestAllowList": { 68 | "Type": "String", 69 | "Default" : "DISALLOW" 70 | }, 71 | "selectedGuestPermissions": { 72 | "Type": "CommaDelimitedList", 73 | "Default" : "NONE" 74 | }, 75 | "selectedAuthenticatedPermissions": { 76 | "Type": "CommaDelimitedList", 77 | "Default" : "NONE" 78 | }, 79 | "env": { 80 | "Type": "String" 81 | }, 82 | "triggerFunction": { 83 | "Type": "String" 84 | } 85 | 86 | 87 | }, 88 | "Conditions": { 89 | "ShouldNotCreateEnvResources": { 90 | "Fn::Equals": [ 91 | { 92 | "Ref": "env" 93 | }, 94 | "NONE" 95 | ] 96 | }, 97 | "CreateAuthPublic": { 98 | "Fn::Not" : [{ 99 | "Fn::Equals" : [ 100 | {"Ref" : "s3PermissionsAuthenticatedPublic"}, 101 | "DISALLOW" 102 | ] 103 | }] 104 | }, 105 | "CreateAuthProtected": { 106 | "Fn::Not" : [{ 107 | "Fn::Equals" : [ 108 | {"Ref" : "s3PermissionsAuthenticatedProtected"}, 109 | "DISALLOW" 110 | ] 111 | }] 112 | }, 113 | "CreateAuthPrivate": { 114 | "Fn::Not" : [{ 115 | "Fn::Equals" : [ 116 | {"Ref" : "s3PermissionsAuthenticatedPrivate"}, 117 | "DISALLOW" 118 | ] 119 | }] 120 | }, 121 | "CreateAuthUploads": { 122 | "Fn::Not" : [{ 123 | "Fn::Equals" : [ 124 | {"Ref" : "s3PermissionsAuthenticatedUploads"}, 125 | "DISALLOW" 126 | ] 127 | }] 128 | }, 129 | "CreateGuestPublic": { 130 | "Fn::Not" : [{ 131 | "Fn::Equals" : [ 132 | {"Ref" : "s3PermissionsGuestPublic"}, 133 | "DISALLOW" 134 | ] 135 | }] 136 | }, 137 | "CreateGuestUploads": { 138 | "Fn::Not" : [{ 139 | "Fn::Equals" : [ 140 | {"Ref" : "s3PermissionsGuestUploads"}, 141 | "DISALLOW" 142 | ] 143 | }] 144 | }, 145 | "AuthReadAndList": { 146 | "Fn::Not" : [{ 147 | "Fn::Equals" : [ 148 | {"Ref" : "AuthenticatedAllowList"}, 149 | "DISALLOW" 150 | ] 151 | }] 152 | }, 153 | "GuestReadAndList": { 154 | "Fn::Not" : [{ 155 | "Fn::Equals" : [ 156 | {"Ref" : "GuestAllowList"}, 157 | "DISALLOW" 158 | ] 159 | }] 160 | } 161 | }, 162 | "Resources": { 163 | "S3Bucket": { 164 | "Type": "AWS::S3::Bucket", 165 | 166 | "DeletionPolicy" : "Retain", 167 | "Properties": { 168 | "BucketName": { 169 | "Fn::If": [ 170 | "ShouldNotCreateEnvResources", 171 | { 172 | "Ref": "bucketName" 173 | }, 174 | { 175 | "Fn::Join": [ 176 | "", 177 | [ 178 | { 179 | "Ref": "bucketName" 180 | }, 181 | { 182 | "Fn::Select": [ 183 | 3, 184 | { 185 | "Fn::Split": [ 186 | "-", 187 | { 188 | "Ref": "AWS::StackName" 189 | } 190 | ] 191 | } 192 | ] 193 | }, 194 | "-", 195 | { 196 | "Ref": "env" 197 | } 198 | ] 199 | ] 200 | } 201 | ] 202 | }, 203 | 204 | "CorsConfiguration": { 205 | "CorsRules": [ 206 | { 207 | "AllowedHeaders": [ 208 | "*" 209 | ], 210 | "AllowedMethods": [ 211 | "GET", 212 | "HEAD", 213 | "PUT", 214 | "POST", 215 | "DELETE" 216 | ], 217 | "AllowedOrigins": [ 218 | "*" 219 | ], 220 | "ExposedHeaders": [ 221 | "x-amz-server-side-encryption", 222 | "x-amz-request-id", 223 | "x-amz-id-2", 224 | "ETag" 225 | ], 226 | "Id": "S3CORSRuleId1", 227 | "MaxAge": "3000" 228 | } 229 | ] 230 | } 231 | } 232 | }, 233 | 234 | 235 | "S3AuthPublicPolicy": { 236 | "DependsOn": [ 237 | "S3Bucket" 238 | ], 239 | "Condition": "CreateAuthPublic", 240 | "Type": "AWS::IAM::Policy", 241 | "Properties": { 242 | "PolicyName": { 243 | "Ref": "s3PublicPolicy" 244 | }, 245 | "Roles": [ 246 | { 247 | "Ref": "authRoleName" 248 | } 249 | ], 250 | "PolicyDocument": { 251 | "Version": "2012-10-17", 252 | "Statement": [ 253 | { 254 | "Effect": "Allow", 255 | "Action": { 256 | "Fn::Split" : [ "," , { 257 | "Ref": "s3PermissionsAuthenticatedPublic" 258 | } ] 259 | }, 260 | "Resource": [ 261 | { 262 | "Fn::Join": [ 263 | "", 264 | [ 265 | "arn:aws:s3:::", 266 | { 267 | "Ref": "S3Bucket" 268 | }, 269 | "/public/*" 270 | ] 271 | ] 272 | } 273 | ] 274 | } 275 | ] 276 | } 277 | } 278 | }, 279 | "S3AuthProtectedPolicy": { 280 | "DependsOn": [ 281 | "S3Bucket" 282 | ], 283 | "Condition": "CreateAuthProtected", 284 | "Type": "AWS::IAM::Policy", 285 | "Properties": { 286 | "PolicyName": { 287 | "Ref": "s3ProtectedPolicy" 288 | }, 289 | "Roles": [ 290 | { 291 | "Ref": "authRoleName" 292 | } 293 | ], 294 | "PolicyDocument": { 295 | "Version": "2012-10-17", 296 | "Statement": [ 297 | { 298 | "Effect": "Allow", 299 | "Action": { 300 | "Fn::Split" : [ "," , { 301 | "Ref": "s3PermissionsAuthenticatedProtected" 302 | } ] 303 | }, 304 | "Resource": [ 305 | { 306 | "Fn::Join": [ 307 | "", 308 | [ 309 | "arn:aws:s3:::", 310 | { 311 | "Ref": "S3Bucket" 312 | }, 313 | "/protected/${cognito-identity.amazonaws.com:sub}/*" 314 | ] 315 | ] 316 | } 317 | ] 318 | } 319 | ] 320 | } 321 | } 322 | }, 323 | "S3AuthPrivatePolicy": { 324 | "DependsOn": [ 325 | "S3Bucket" 326 | ], 327 | "Condition": "CreateAuthPrivate", 328 | "Type": "AWS::IAM::Policy", 329 | "Properties": { 330 | "PolicyName": { 331 | "Ref": "s3PrivatePolicy" 332 | }, 333 | "Roles": [ 334 | { 335 | "Ref": "authRoleName" 336 | } 337 | ], 338 | "PolicyDocument": { 339 | "Version": "2012-10-17", 340 | "Statement": [ 341 | { 342 | "Effect": "Allow", 343 | "Action": { 344 | "Fn::Split" : [ "," , { 345 | "Ref": "s3PermissionsAuthenticatedPrivate" 346 | } ] 347 | }, 348 | "Resource": [ 349 | { 350 | "Fn::Join": [ 351 | "", 352 | [ 353 | "arn:aws:s3:::", 354 | { 355 | "Ref": "S3Bucket" 356 | }, 357 | "/private/${cognito-identity.amazonaws.com:sub}/*" 358 | ] 359 | ] 360 | } 361 | ] 362 | } 363 | ] 364 | } 365 | } 366 | }, 367 | "S3AuthUploadPolicy": { 368 | "DependsOn": [ 369 | "S3Bucket" 370 | ], 371 | "Condition": "CreateAuthUploads", 372 | "Type": "AWS::IAM::Policy", 373 | "Properties": { 374 | "PolicyName": { 375 | "Ref": "s3UploadsPolicy" 376 | }, 377 | "Roles": [ 378 | { 379 | "Ref": "authRoleName" 380 | } 381 | ], 382 | "PolicyDocument": { 383 | "Version": "2012-10-17", 384 | "Statement": [ 385 | { 386 | "Effect": "Allow", 387 | "Action": { 388 | "Fn::Split" : [ "," , { 389 | "Ref": "s3PermissionsAuthenticatedUploads" 390 | } ] 391 | }, 392 | "Resource": [ 393 | { 394 | "Fn::Join": [ 395 | "", 396 | [ 397 | "arn:aws:s3:::", 398 | { 399 | "Ref": "S3Bucket" 400 | }, 401 | "/uploads/*" 402 | ] 403 | ] 404 | } 405 | ] 406 | } 407 | ] 408 | } 409 | } 410 | }, 411 | "S3AuthReadPolicy": { 412 | "DependsOn": [ 413 | "S3Bucket" 414 | ], 415 | "Condition": "AuthReadAndList", 416 | "Type": "AWS::IAM::Policy", 417 | "Properties": { 418 | "PolicyName": { 419 | "Ref": "s3ReadPolicy" 420 | }, 421 | "Roles": [ 422 | { 423 | "Ref": "authRoleName" 424 | } 425 | ], 426 | "PolicyDocument": { 427 | "Version": "2012-10-17", 428 | "Statement": [ 429 | { 430 | "Effect": "Allow", 431 | "Action": [ 432 | "s3:GetObject" 433 | ], 434 | "Resource": [ 435 | { 436 | "Fn::Join": [ 437 | "", 438 | [ 439 | "arn:aws:s3:::", 440 | { 441 | "Ref": "S3Bucket" 442 | }, 443 | "/protected/*" 444 | ] 445 | ] 446 | } 447 | ] 448 | }, 449 | { 450 | "Effect": "Allow", 451 | "Action": [ 452 | "s3:ListBucket" 453 | ], 454 | "Resource": [ 455 | { 456 | "Fn::Join": [ 457 | "", 458 | [ 459 | "arn:aws:s3:::", 460 | { 461 | "Ref": "S3Bucket" 462 | } 463 | ] 464 | ] 465 | } 466 | ], 467 | "Condition": { 468 | "StringLike": { 469 | "s3:prefix": [ 470 | "public/", 471 | "public/*", 472 | "protected/", 473 | "protected/*", 474 | "private/${cognito-identity.amazonaws.com:sub}/", 475 | "private/${cognito-identity.amazonaws.com:sub}/*" 476 | ] 477 | } 478 | } 479 | } 480 | ] 481 | } 482 | } 483 | }, 484 | "S3GuestPublicPolicy": { 485 | "DependsOn": [ 486 | "S3Bucket" 487 | ], 488 | "Condition": "CreateGuestPublic", 489 | "Type": "AWS::IAM::Policy", 490 | "Properties": { 491 | "PolicyName": { 492 | "Ref": "s3PublicPolicy" 493 | }, 494 | "Roles": [ 495 | { 496 | "Ref": "unauthRoleName" 497 | } 498 | ], 499 | "PolicyDocument": { 500 | "Version": "2012-10-17", 501 | "Statement": [ 502 | { 503 | "Effect": "Allow", 504 | "Action": { 505 | "Fn::Split" : [ "," , { 506 | "Ref": "s3PermissionsGuestPublic" 507 | } ] 508 | }, 509 | "Resource": [ 510 | { 511 | "Fn::Join": [ 512 | "", 513 | [ 514 | "arn:aws:s3:::", 515 | { 516 | "Ref": "S3Bucket" 517 | }, 518 | "/public/*" 519 | ] 520 | ] 521 | } 522 | ] 523 | } 524 | ] 525 | } 526 | } 527 | }, 528 | "S3GuestUploadPolicy": { 529 | "DependsOn": [ 530 | "S3Bucket" 531 | ], 532 | "Condition": "CreateGuestUploads", 533 | "Type": "AWS::IAM::Policy", 534 | "Properties": { 535 | "PolicyName": { 536 | "Ref": "s3UploadsPolicy" 537 | }, 538 | "Roles": [ 539 | { 540 | "Ref": "unauthRoleName" 541 | } 542 | ], 543 | "PolicyDocument": { 544 | "Version": "2012-10-17", 545 | "Statement": [ 546 | { 547 | "Effect": "Allow", 548 | "Action": { 549 | "Fn::Split" : [ "," , { 550 | "Ref": "s3PermissionsGuestUploads" 551 | } ] 552 | }, 553 | "Resource": [ 554 | { 555 | "Fn::Join": [ 556 | "", 557 | [ 558 | "arn:aws:s3:::", 559 | { 560 | "Ref": "S3Bucket" 561 | }, 562 | "/uploads/*" 563 | ] 564 | ] 565 | } 566 | ] 567 | } 568 | ] 569 | } 570 | } 571 | }, 572 | "S3GuestReadPolicy": { 573 | "DependsOn": [ 574 | "S3Bucket" 575 | ], 576 | "Condition": "GuestReadAndList", 577 | "Type": "AWS::IAM::Policy", 578 | "Properties": { 579 | "PolicyName": { 580 | "Ref": "s3ReadPolicy" 581 | }, 582 | "Roles": [ 583 | { 584 | "Ref": "unauthRoleName" 585 | } 586 | ], 587 | "PolicyDocument": { 588 | "Version": "2012-10-17", 589 | "Statement": [ 590 | { 591 | "Effect": "Allow", 592 | "Action": [ 593 | "s3:GetObject" 594 | ], 595 | "Resource": [ 596 | { 597 | "Fn::Join": [ 598 | "", 599 | [ 600 | "arn:aws:s3:::", 601 | { 602 | "Ref": "S3Bucket" 603 | }, 604 | "/protected/*" 605 | ] 606 | ] 607 | } 608 | ] 609 | }, 610 | { 611 | "Effect": "Allow", 612 | "Action": [ 613 | "s3:ListBucket" 614 | ], 615 | "Resource": [ 616 | { 617 | "Fn::Join": [ 618 | "", 619 | [ 620 | "arn:aws:s3:::", 621 | { 622 | "Ref": "S3Bucket" 623 | } 624 | ] 625 | ] 626 | } 627 | ], 628 | "Condition": { 629 | "StringLike": { 630 | "s3:prefix": [ 631 | "public/", 632 | "public/*", 633 | "protected/", 634 | "protected/*" 635 | ] 636 | } 637 | } 638 | } 639 | ] 640 | } 641 | } 642 | } 643 | }, 644 | "Outputs": { 645 | "BucketName": { 646 | "Value": { 647 | "Ref": "S3Bucket" 648 | }, 649 | "Description": "Bucket name for the S3 bucket" 650 | }, 651 | "Region": { 652 | "Value": { 653 | "Ref": "AWS::Region" 654 | } 655 | } 656 | } 657 | } 658 | -------------------------------------------------------------------------------- /amplify/backend/storage/images/storage-params.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "savvy", 3 | "version": "1.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "@aws-amplify/api": "^3.2.23", 7 | "@aws-amplify/auth": "^3.4.23", 8 | "@aws-amplify/core": "^3.2.6", 9 | "@aws-amplify/storage": "^3.3.23", 10 | "@testing-library/jest-dom": "^4.2.4", 11 | "@testing-library/react": "^9.3.2", 12 | "@testing-library/user-event": "^7.1.2", 13 | "disqus-react": "^1.0.11", 14 | "react": "^16.13.1", 15 | "react-dom": "^16.13.1", 16 | "react-router-dom": "^5.2.0", 17 | "react-scripts": "3.4.1", 18 | "react-toastify": "^6.0.4", 19 | "uuid": "^7.0.3" 20 | }, 21 | "scripts": { 22 | "start": "npm run watch:css && react-scripts start", 23 | "build": "npm run build:css && react-scripts build", 24 | "test": "react-scripts test", 25 | "eject": "react-scripts eject", 26 | "build:css": "postcss src/assets/tailwind.css -o src/assets/main.css", 27 | "watch:css": "postcss src/assets/tailwind.css -o src/assets/main.css" 28 | }, 29 | "eslintConfig": { 30 | "extends": "react-app" 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.2%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 1 chrome version", 40 | "last 1 firefox version", 41 | "last 1 safari version" 42 | ] 43 | }, 44 | "devDependencies": { 45 | "autoprefixer": "^9.7.6", 46 | "postcss-cli": "^7.1.0", 47 | "tailwindcss": "^1.3.4" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | const tailwindcss = require("tailwindcss"); 2 | module.exports = { 3 | plugins: [tailwindcss("./tailwind.js"), require("autoprefixer")], 4 | }; 5 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liyasthomas/savvy/168067dab1929b1cd76294026574254ec2df88f6/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 15 | 16 | 17 | 21 | 25 | 26 | 35 | Savvy 36 | 37 | 38 | 39 |
40 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liyasthomas/savvy/168067dab1929b1cd76294026574254ec2df88f6/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liyasthomas/savvy/168067dab1929b1cd76294026574254ec2df88f6/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Savvy", 3 | "name": "Savvy", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#ffffff", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /savvy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liyasthomas/savvy/168067dab1929b1cd76294026574254ec2df88f6/savvy.png -------------------------------------------------------------------------------- /src/About.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | export default function About() { 3 | return ( 4 |
5 |

6 | Build better products with customer feedback 7 |

8 |

9 | Savvy is a scalable serverless customer feedback app - built with AWS 10 | Amplify, AWS AppSync, and Amazon DynamoDB on{" "} 11 | 17 | AWS Amplify Hackathon by Hashnode 18 | 19 | . 20 |
21 |
22 | 28 | GitHub repository 29 | 30 |

31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/Button.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function Button({ 4 | onClick, 5 | title, 6 | backgroundColor = "#6dd345", 7 | disabled, 8 | }) { 9 | return ( 10 | 17 | ); 18 | } 19 | 20 | const buttonStyle = (backgroundColor, disabled) => ({ 21 | background: backgroundColor, 22 | borderRadius: 8, 23 | display: "flex", 24 | alignItems: "center", 25 | justifyContent: "center", 26 | color: "white", 27 | outline: "none", 28 | border: "none", 29 | fontWeight: "600", 30 | opacity: disabled ? 0.5 : 1, 31 | padding: "8px 16px", 32 | margin: "4px 8px", 33 | textShadow: "0 1px 2px rgb(0 0 0 / 20%)", 34 | }); 35 | -------------------------------------------------------------------------------- /src/Candidates.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { STORAGE_KEY } from "./utils/localStorageInfo"; 3 | import votePink from "./assets/votepink.svg"; 4 | import voteBlue from "./assets/voteblue.svg"; 5 | import Button from "./Button"; 6 | import { toast, ToastContainer } from "react-toastify"; 7 | 8 | export default function Candidates({ 9 | poll, 10 | candidates, 11 | onUpVote, 12 | simulateUpvotes, 13 | pollView = false, 14 | }) { 15 | // const isImage = poll.type === "image"; 16 | let totalUpvotes; 17 | let candidate1; 18 | let candidate2; 19 | if (pollView) { 20 | /* If this is poll view, create percentages for chart */ 21 | totalUpvotes = candidates.reduce((acc, { upvotes }) => acc + upvotes, 0); 22 | candidate1 = candidates[0].upvotes 23 | ? (candidates[0].upvotes / totalUpvotes) * 100 24 | : 0; 25 | candidate2 = candidates[1].upvotes 26 | ? (candidates[1].upvotes / totalUpvotes) * 100 27 | : 0; 28 | } 29 | if (totalUpvotes === 0) { 30 | /* If poll is new, set 50% width for each side of chart */ 31 | candidate1 = 50; 32 | candidate2 = 50; 33 | } 34 | 35 | const voteDataFromStorage = JSON.parse(localStorage.getItem(STORAGE_KEY)); 36 | if (voteDataFromStorage && voteDataFromStorage[poll.id]) { 37 | /* If user has voted 50 times for a candidate, disable voting */ 38 | const c1 = voteDataFromStorage[poll.id][candidates[0].id]; 39 | const c2 = voteDataFromStorage[poll.id][candidates[1].id]; 40 | if (c1 && c1.upvotes >= 50) candidates[0].isDisabled = true; 41 | if (c2 && c2.upvotes >= 50) candidates[1].isDisabled = true; 42 | } 43 | 44 | return ( 45 |
46 |
47 | {candidates.map((candidate, index) => { 48 | if (poll.type === "text") { 49 | return ( 50 |
51 |
52 |
onUpVote(candidate, poll) 59 | } 60 | > 61 | Candidate 65 |
66 |

70 | {candidate.upvotes} 71 |

72 |
73 |
74 |

{candidate.name}

75 |
76 |
77 | ); 78 | } 79 | return ( 80 |
81 |
82 | 88 |
89 |
90 | Candidate 96 |
97 |
98 | ); 99 | })} 100 |
101 | { 102 | /* This is the data vizualization. Essentially a rectangle filled with the percentage width of each candidate. */ 103 | pollView && ( 104 |
105 |
106 |
107 |
108 | ) 109 | } 110 | {pollView && ( 111 |
112 |
124 | )} 125 |
126 | ); 127 | } 128 | 129 | function ImageVoteBlock({ index, candidate, poll, onUpVote }) { 130 | return ( 131 |
132 |
onUpVote(candidate, poll)} 136 | > 137 | Candidate 138 |
139 |

140 | {candidate.upvotes} 141 |

142 |
143 | ); 144 | } 145 | 146 | const dataVizStyle = { 147 | width: "100%", 148 | height: 46, 149 | display: "flex", 150 | marginTop: 16, 151 | borderRadius: 10, 152 | }; 153 | 154 | function candidate1Style(width) { 155 | return { 156 | backgroundColor: "#8B5CF6", 157 | width: `${width}%`, 158 | borderTopLeftRadius: 5, 159 | borderBottomLeftRadius: 5, 160 | transition: "all 0.5s ease", 161 | }; 162 | } 163 | 164 | function candidate2Style(width) { 165 | return { 166 | backgroundColor: "#3B82F6", 167 | width: `${width}%`, 168 | borderTopRightRadius: 5, 169 | borderBottomRightRadius: 5, 170 | transition: "all 0.5s ease", 171 | }; 172 | } 173 | 174 | const voteImageContainerStyle = (index, isDisabled) => ({ 175 | backgroundColor: index === Number(0) ? "#8B5CF6" : "#3B82F6", 176 | opacity: isDisabled ? 0.5 : 1, 177 | cursor: isDisabled ? "auto" : "pointer", 178 | }); 179 | 180 | function candidateImageStyle(index) { 181 | // const indexzero = index === Number(0); 182 | return { 183 | // border: `1px solid ${indexzero ? "#8B5CF6" : "#3B82F6"}`, 184 | objectFit: "contain", 185 | }; 186 | } 187 | 188 | function voteNameStyle(index) { 189 | const indexzero = index === Number(0); 190 | return { 191 | color: indexzero ? "#8B5CF6" : "#3B82F6", 192 | }; 193 | } 194 | -------------------------------------------------------------------------------- /src/CreatePoll.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { useHistory } from "react-router-dom"; 3 | import { v4 as uuid } from "uuid"; 4 | import API from "@aws-amplify/api"; 5 | import Storage from "@aws-amplify/storage"; 6 | import { 7 | createPoll as createPollMutation, 8 | createCandidate as createCandidateMutation, 9 | } from "./graphql/mutations"; 10 | import { getPoll as getPollQuery } from "./graphql/queries"; 11 | import slugify from "./utils/slugify"; 12 | import Button from "./Button"; 13 | import loading from "./loading.svg"; 14 | 15 | let counter; 16 | let pollId; 17 | 18 | const initialState = { 19 | pollType: null, 20 | candidate1: null, 21 | candidate2: null, 22 | pollName: null, 23 | isUploading: false, 24 | }; 25 | 26 | export default function CreatePoll() { 27 | const [state, setState] = useState(initialState); 28 | const history = useHistory(); 29 | function setPollType(type) { 30 | setState(() => ({ ...initialState, pollType: type })); 31 | } 32 | 33 | function onChangeText({ target }) { 34 | const { name, value } = target; 35 | setState((currentState) => ({ ...currentState, [name]: value })); 36 | } 37 | async function onChangeImage(e) { 38 | if (!e.target.files[0]) return; 39 | setState((currentState) => ({ ...currentState, isUploading: true })); 40 | e.persist(); 41 | const file = e.target.files[0]; 42 | const fileName = `${uuid()}_${file.name}`; 43 | setState((currentState) => ({ 44 | ...currentState, 45 | [e.target.name]: { 46 | localFile: URL.createObjectURL(file), 47 | file, 48 | fileName, 49 | }, 50 | })); 51 | await Storage.put(fileName, file); 52 | setState((currentState) => ({ ...currentState, isUploading: false })); 53 | } 54 | 55 | async function createPoll() { 56 | /* Check if poll name is already taken, if so append a version # to the name 57 | * then create the poll. 58 | */ 59 | const { pollType } = state; 60 | let { pollName } = state; 61 | try { 62 | pollId = slugify(pollName); 63 | if (counter) { 64 | pollId = `${pollId}-v-${counter}`; 65 | } 66 | const data = await API.graphql({ 67 | query: getPollQuery, 68 | variables: { 69 | id: pollId, 70 | }, 71 | }); 72 | if (data.data.getPoll) { 73 | counter ? (counter = counter + 1) : (counter = 2); 74 | return createPoll(); 75 | } 76 | } catch (err) {} 77 | try { 78 | if (counter) { 79 | pollName = `${pollName}-v-${counter}`; 80 | } 81 | const pollData = { 82 | id: pollId, 83 | itemType: "Poll", 84 | type: pollType, 85 | name: pollName, 86 | }; 87 | const isImage = pollType === "image"; 88 | 89 | const candidate1Data = { 90 | pollCandidatesId: pollId, 91 | upvotes: 0, 92 | name: isImage ? null : candidate1, 93 | image: isImage ? candidate1.fileName : null, 94 | }; 95 | const candidate2Data = { 96 | pollCandidatesId: pollId, 97 | upvotes: 0, 98 | name: isImage ? null : candidate2, 99 | image: isImage ? candidate2.fileName : null, 100 | }; 101 | 102 | const createPollPromise = API.graphql({ 103 | query: createPollMutation, 104 | variables: { input: pollData }, 105 | }); 106 | const createCandidate1Promise = API.graphql({ 107 | query: createCandidateMutation, 108 | variables: { input: candidate1Data }, 109 | }); 110 | const createCandidate2Promise = API.graphql({ 111 | query: createCandidateMutation, 112 | variables: { input: candidate2Data }, 113 | }); 114 | await Promise.all([ 115 | createPollPromise, 116 | createCandidate1Promise, 117 | createCandidate2Promise, 118 | ]); 119 | 120 | const url = `/${pollId}`; 121 | history.push(url); 122 | } catch (err) { 123 | console.log("error: ", err); 124 | } 125 | } 126 | 127 | const { pollType, candidate1, candidate2, pollName, isUploading } = state; 128 | const isDisabled = !pollType || !candidate1 || !candidate2 || !pollName; 129 | 130 | return ( 131 |
132 |

133 | Create new feature request 134 |

135 |
136 |

137 | What type of poll would you like to create? 138 |

139 |
140 |
151 |
152 | {pollType === "text" && ( 153 |
154 |

Brief description of the feature

155 | 162 |

Up vote label

163 | 170 |

Down vote label

171 | 178 |
179 | )} 180 | {pollType === "image" && ( 181 |
182 |

Brief description of the feature

183 | 190 |
191 |

Up vote image

192 | {state.candidate1 && ( 193 | Candidate 198 | )} 199 |
200 | 207 | 214 |
215 |

Down vote image

216 | {state.candidate2 && ( 217 | Candidate 222 | )} 223 |
224 | 231 | 238 |
239 |
240 |
241 | )} 242 | {isUploading && ( 243 |
244 | Loading 245 | Uploading 246 |
247 | )} 248 | {pollType && ( 249 |
250 |
257 | )} 258 |
259 | ); 260 | } 261 | 262 | const imageStyle = { 263 | width: "100%", 264 | maxWidth: 200, 265 | borderRadius: 8, 266 | marginTop: 32, 267 | marginBottom: 32, 268 | }; 269 | 270 | const inputFileStyle = { 271 | width: "0.1px", 272 | height: "0.1px", 273 | opacity: 0, 274 | overflow: "hidden", 275 | position: "absolute", 276 | zIndex: -1, 277 | }; 278 | 279 | const inputLabelStyle = { 280 | cursor: "pointer", 281 | }; 282 | -------------------------------------------------------------------------------- /src/Footer.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function Footer() { 4 | return
Savvy
; 5 | } 6 | -------------------------------------------------------------------------------- /src/Header.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link, useHistory } from "react-router-dom"; 3 | import logo from "./logo.svg"; 4 | import Button from "./Button"; 5 | 6 | export default function Header() { 7 | const history = useHistory(); 8 | function createPoll() { 9 | history.push("/create"); 10 | } 11 | return ( 12 |
13 | 17 | Logo 18 | Savvy 19 | 20 |
21 | 22 | About 23 | 24 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/Poll.js: -------------------------------------------------------------------------------- 1 | import React, { useReducer, useEffect } from "react"; 2 | import { useParams, useHistory } from "react-router-dom"; 3 | import API from "@aws-amplify/api"; 4 | import Storage from "@aws-amplify/storage"; 5 | import { CLIENT_ID, setVoteForPoll } from "./utils/localStorageInfo"; 6 | import { onUpdateByID } from "./gql/subscriptions"; 7 | import { getPoll } from "./gql/queries"; 8 | import { upVote } from "./gql/mutations"; 9 | import Candidates from "./Candidates"; 10 | import actionTypes from "./actionTypes"; 11 | import loading from "./loading.svg"; 12 | import Disqus from "disqus-react"; 13 | 14 | const initialState = { 15 | loading: true, 16 | poll: {}, 17 | }; 18 | 19 | function reducer(state, action) { 20 | switch (action.type) { 21 | case actionTypes.SET_POLL: 22 | return { 23 | ...state, 24 | poll: action.poll, 25 | loading: false, 26 | }; 27 | case actionTypes.SET_LOADING: 28 | return { 29 | ...state, 30 | loading: action.loading, 31 | }; 32 | case actionTypes.UPVOTE: 33 | const poll = { ...state.poll }; 34 | const identifiedCandidate = poll.candidates.items.find( 35 | ({ id }) => id === action.id 36 | ); 37 | const candidateIndex = poll.candidates.items.findIndex( 38 | ({ id }) => id === action.id 39 | ); 40 | identifiedCandidate.upvotes = identifiedCandidate.upvotes + 1; 41 | poll.candidates.items[candidateIndex] = identifiedCandidate; 42 | return { 43 | ...state, 44 | poll, 45 | }; 46 | default: 47 | return state; 48 | } 49 | } 50 | 51 | export default function Poll() { 52 | const [state, dispatch] = useReducer(reducer, initialState); 53 | 54 | let params = useParams(); 55 | let history = useHistory(); 56 | let subscription1; 57 | let subscription2; 58 | 59 | useEffect(() => { 60 | // window.scrollTo(0, 0); 61 | fetchPoll(); 62 | return () => { 63 | subscription1 && subscription1.unsubscribe(); 64 | subscription2 && subscription1.unsubscribe(); 65 | }; 66 | }); 67 | 68 | async function fetchPoll() { 69 | try { 70 | const { id } = params; 71 | let { 72 | data: { getPoll: pollData }, 73 | } = await API.graphql({ 74 | query: getPoll, 75 | variables: { id }, 76 | }); 77 | if (pollData.type === "image") { 78 | await Promise.all( 79 | pollData.candidates.items.map(async (c) => { 80 | const image = await Storage.get(c.image); 81 | c.image = image; 82 | return image; 83 | }) 84 | ); 85 | } 86 | dispatch({ type: actionTypes.SET_POLL, poll: pollData }); 87 | subscribe(pollData); 88 | } catch (err) { 89 | console.log("error fetching poll: ", err); 90 | history.push("/"); 91 | } 92 | } 93 | 94 | function simulateUpvotes(candidate) { 95 | let i = 0; 96 | setInterval(() => { 97 | i = i + 1; 98 | if (i > 10000) return; 99 | onUpVote(candidate); 100 | }, 1); 101 | } 102 | 103 | function subscribe({ candidates }) { 104 | const { items } = candidates; 105 | const id1 = items[0].id; 106 | const id2 = items[1].id; 107 | 108 | subscription1 = API.graphql({ 109 | query: onUpdateByID, 110 | variables: { id: id1 }, 111 | }).subscribe({ 112 | next: (apiData) => { 113 | const { 114 | value: { 115 | data: { 116 | onUpdateByID: { id, clientId }, 117 | }, 118 | }, 119 | } = apiData; 120 | if (clientId === CLIENT_ID) return; 121 | dispatch({ type: actionTypes.UPVOTE, id }); 122 | }, 123 | }); 124 | 125 | subscription2 = API.graphql({ 126 | query: onUpdateByID, 127 | variables: { id: id2 }, 128 | }).subscribe({ 129 | next: (apiData) => { 130 | const { 131 | value: { 132 | data: { 133 | onUpdateByID: { id, clientId }, 134 | }, 135 | }, 136 | } = apiData; 137 | if (clientId === CLIENT_ID) return; 138 | dispatch({ type: actionTypes.UPVOTE, id }); 139 | }, 140 | }); 141 | } 142 | 143 | async function onUpVote({ id }) { 144 | const limitReached = setVoteForPoll(state.poll.id, id); 145 | if (limitReached) return; 146 | dispatch({ type: actionTypes.UPVOTE, id: id }); 147 | const voteData = { id: id, clientId: CLIENT_ID }; 148 | try { 149 | await API.graphql({ 150 | query: upVote, 151 | variables: voteData, 152 | }); 153 | } catch (err) { 154 | console.log("error upvoting: ", err); 155 | } 156 | } 157 | 158 | if (state.loading) 159 | return ( 160 |
161 | Loading 162 | Loading 163 |
164 | ); 165 | 166 | const disqusShortname = "savvy-feedback"; // Replace this with your own Disqus unique short name 167 | const disqusConfig = { 168 | url: window.location.href, 169 | identifier: params.id, 170 | title: state.poll.name, 171 | }; 172 | 173 | return ( 174 |
175 |

176 | {state.poll.name} 177 |

178 | 185 |
186 | 190 |
191 |
192 | ); 193 | } 194 | -------------------------------------------------------------------------------- /src/Polls.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useReducer } from "react"; 2 | import API from "@aws-amplify/api"; 3 | import Storage from "@aws-amplify/storage"; 4 | import { Link } from "react-router-dom"; 5 | import { itemsByType } from "./gql/queries"; 6 | import { onUpdateByID } from "./gql/subscriptions"; 7 | import { upVote } from "./gql/mutations"; 8 | import { setVoteForPoll, CLIENT_ID } from "./utils/localStorageInfo"; 9 | import Candidates from "./Candidates"; 10 | import actionTypes from "./actionTypes"; 11 | import loading from "./loading.svg"; 12 | 13 | const initialState = { 14 | polls: [], 15 | loading: true, 16 | }; 17 | 18 | function reducer(state, action) { 19 | switch (action.type) { 20 | case actionTypes.SET_POLL: 21 | return { 22 | ...state, 23 | polls: action.polls, 24 | loading: false, 25 | }; 26 | case actionTypes.UPVOTE: 27 | const { pollId, candidateId } = action; 28 | const polls = state.polls; 29 | const poll = polls.find(({ id }) => id === pollId); 30 | const pollIndex = polls.findIndex(({ id }) => id === pollId); 31 | const identifiedCandidate = poll.candidates.items.find( 32 | ({ id }) => id === candidateId 33 | ); 34 | const candidateIndex = poll.candidates.items.findIndex( 35 | ({ id }) => id === candidateId 36 | ); 37 | identifiedCandidate.upvotes = identifiedCandidate.upvotes + 1; 38 | polls[pollIndex].candidates.items[candidateIndex] = identifiedCandidate; 39 | return { 40 | ...state, 41 | polls, 42 | }; 43 | default: 44 | return state; 45 | } 46 | } 47 | 48 | export default function Polls() { 49 | const [state, dispatch] = useReducer(reducer, initialState); 50 | 51 | const subscriptions = {}; 52 | useEffect(() => { 53 | fetchPolls(); 54 | return () => { 55 | Object.values(subscriptions).forEach((subscription) => 56 | subscription.unsubscribe() 57 | ); 58 | }; 59 | }, []); 60 | async function fetchPolls() { 61 | const pollData = await API.graphql({ 62 | query: itemsByType, 63 | variables: { 64 | sortDirection: "ASC", 65 | itemType: "Poll", 66 | limit: 5, 67 | }, 68 | }); 69 | await Promise.all( 70 | pollData.data.itemsByType.items.map(async ({ candidates }) => { 71 | await Promise.all( 72 | candidates.items.map(async (c) => { 73 | const image = await Storage.get(c.image); 74 | c.image = image; 75 | return image; 76 | }) 77 | ); 78 | }) 79 | ); 80 | dispatch({ 81 | type: actionTypes.SET_POLL, 82 | polls: pollData.data.itemsByType.items, 83 | }); 84 | pollData.data.itemsByType.items.forEach((item) => subscribe(item)); 85 | } 86 | 87 | function subscribe(pollData) { 88 | const { items } = pollData.candidates; 89 | const { id: pollId } = pollData; 90 | const id1 = items[0].id; 91 | const id2 = items[1].id; 92 | 93 | subscriptions[id1] = API.graphql({ 94 | query: onUpdateByID, 95 | variables: { id: id1 }, 96 | }).subscribe({ 97 | next: (apiData) => { 98 | const { 99 | value: { 100 | data: { 101 | onUpdateByID: { id, clientId }, 102 | }, 103 | }, 104 | } = apiData; 105 | if (clientId === CLIENT_ID) return; 106 | dispatch({ type: actionTypes.UPVOTE, pollId, candidateId: id }); 107 | }, 108 | }); 109 | 110 | subscriptions[id2] = API.graphql({ 111 | query: onUpdateByID, 112 | variables: { id: id2 }, 113 | }).subscribe({ 114 | next: (apiData) => { 115 | const { 116 | value: { 117 | data: { 118 | onUpdateByID: { id, clientId }, 119 | }, 120 | }, 121 | } = apiData; 122 | if (clientId === CLIENT_ID) return; 123 | dispatch({ type: actionTypes.UPVOTE, pollId, candidateId: id }); 124 | }, 125 | }); 126 | } 127 | 128 | function createLocalUpvote(candidateId, pollId) { 129 | const limitReached = setVoteForPoll(pollId, candidateId); 130 | if (limitReached) return; 131 | const polls = state.polls; 132 | const poll = polls.find(({ id }) => id === pollId); 133 | const pollIndex = polls.findIndex(({ id }) => id === pollId); 134 | const identifiedCandidate = poll.candidates.items.find( 135 | ({ id }) => id === candidateId 136 | ); 137 | const candidateIndex = poll.candidates.items.findIndex( 138 | ({ id }) => id === candidateId 139 | ); 140 | identifiedCandidate.upvotes = identifiedCandidate.upvotes + 1; 141 | polls[pollIndex].candidates.items[candidateIndex] = identifiedCandidate; 142 | dispatch({ type: actionTypes.SET_POLL, polls }); 143 | } 144 | async function onUpVote({ id: candidateId }, { id: pollId }) { 145 | createLocalUpvote(candidateId, pollId); 146 | upvoteApi(candidateId); 147 | } 148 | async function upvoteApi(id) { 149 | await API.graphql({ 150 | query: upVote, 151 | variables: { id, clientId: CLIENT_ID }, 152 | }); 153 | } 154 | if (state.loading) 155 | return ( 156 |
157 | Loading 158 | Loading 159 |
160 | ); 161 | 162 | return ( 163 |
164 |

165 | Collect feedback from customers and public 166 |

167 |

168 | Savvy is a scalable serverless customer feedback app - built with AWS 169 | Amplify, AWS AppSync, and Amazon DynamoDB on{" "} 170 | 176 | AWS Amplify Hackathon by Hashnode 177 | 178 | . 179 |

180 |

181 | Savvy runs on Savvy. Feel free to add new feature requests that you'd 182 | like to see implemented in Savvy or up vote your favorite ones that are 183 | already listed below. Down voting a feature shows less intrest in it. 184 |

185 |

186 | Feature requests 187 |

188 |
189 | {state.polls.map((poll, index) => ( 190 |
191 | 197 |

198 | 199 | {poll.name} 200 | 201 |

202 |
203 | ))} 204 |
205 |

206 | Setup your own Savvy 207 |

208 |

209 | Savvy ♥ open source. 210 |
211 |
212 | We build Savvy for developers, entrepreneures, indie hackers, and 213 | startups. Deploy your own self-hosted Savvy instance to AWS Amplify with 214 | one-click and start building better products. 215 |
216 |
217 | 223 | Deploy to AWS Amplify 224 | 225 |
226 |
227 | Follow the detailed instructions on our{" "} 228 | 234 | GitHub repository 235 | 236 | . 237 |

238 |
239 | ); 240 | } 241 | -------------------------------------------------------------------------------- /src/Router.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { BrowserRouter, Switch, Route } from "react-router-dom"; 3 | 4 | import Polls from "./Polls"; 5 | import Poll from "./Poll"; 6 | import Header from "./Header"; 7 | import Footer from "./Footer"; 8 | import CreatePoll from "./CreatePoll"; 9 | import About from "./About"; 10 | 11 | export default function Router() { 12 | return ( 13 | 14 |
15 |
16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
32 |
33 |
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/actionTypes.js: -------------------------------------------------------------------------------- 1 | const actionTypes = { 2 | SET_POLL: "SET_POLL", 3 | UPVOTE: "UPVOTE", 4 | SET_LOADING: "SET_LOADING", 5 | }; 6 | 7 | export default actionTypes; 8 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liyasthomas/savvy/168067dab1929b1cd76294026574254ec2df88f6/src/assets/logo.png -------------------------------------------------------------------------------- /src/assets/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /src/assets/voteblue.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/votepink.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/gql/mutations.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // this is an auto generated file. This will be overwritten 3 | 4 | export const upVote = /* GraphQL */ ` 5 | mutation UpVote($id: ID, $clientId: ID) { 6 | upVote(id: $id, clientId: $clientId) { 7 | id 8 | clientId 9 | } 10 | } 11 | `; 12 | export const createPoll = /* GraphQL */ ` 13 | mutation CreatePoll( 14 | $input: CreatePollInput! 15 | $condition: ModelPollConditionInput 16 | ) { 17 | createPoll(input: $input, condition: $condition) { 18 | id 19 | name 20 | type 21 | candidates { 22 | nextToken 23 | } 24 | itemType 25 | createdAt 26 | } 27 | } 28 | `; 29 | export const updatePoll = /* GraphQL */ ` 30 | mutation UpdatePoll( 31 | $input: UpdatePollInput! 32 | $condition: ModelPollConditionInput 33 | ) { 34 | updatePoll(input: $input, condition: $condition) { 35 | id 36 | name 37 | type 38 | candidates { 39 | nextToken 40 | } 41 | itemType 42 | createdAt 43 | } 44 | } 45 | `; 46 | export const deletePoll = /* GraphQL */ ` 47 | mutation DeletePoll( 48 | $input: DeletePollInput! 49 | $condition: ModelPollConditionInput 50 | ) { 51 | deletePoll(input: $input, condition: $condition) { 52 | id 53 | name 54 | type 55 | candidates { 56 | nextToken 57 | } 58 | itemType 59 | createdAt 60 | } 61 | } 62 | `; 63 | export const createCandidate = /* GraphQL */ ` 64 | mutation CreateCandidate( 65 | $input: CreateCandidateInput! 66 | $condition: ModelCandidateConditionInput 67 | ) { 68 | createCandidate(input: $input, condition: $condition) { 69 | id 70 | pollCandidatesId 71 | image 72 | name 73 | upvotes 74 | } 75 | } 76 | `; 77 | export const updateCandidate = /* GraphQL */ ` 78 | mutation UpdateCandidate( 79 | $input: UpdateCandidateInput! 80 | $condition: ModelCandidateConditionInput 81 | ) { 82 | updateCandidate(input: $input, condition: $condition) { 83 | id 84 | pollCandidatesId 85 | image 86 | name 87 | upvotes 88 | } 89 | } 90 | `; 91 | export const deleteCandidate = /* GraphQL */ ` 92 | mutation DeleteCandidate( 93 | $input: DeleteCandidateInput! 94 | $condition: ModelCandidateConditionInput 95 | ) { 96 | deleteCandidate(input: $input, condition: $condition) { 97 | id 98 | pollCandidatesId 99 | image 100 | name 101 | upvotes 102 | } 103 | } 104 | `; 105 | -------------------------------------------------------------------------------- /src/gql/queries.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // this is an auto generated file. This will be overwritten 3 | 4 | export const getPoll = /* GraphQL */ ` 5 | query GetPoll($id: ID!) { 6 | getPoll(id: $id) { 7 | id 8 | name 9 | type 10 | candidates { 11 | items { 12 | id 13 | image 14 | name 15 | upvotes 16 | } 17 | } 18 | itemType 19 | createdAt 20 | } 21 | } 22 | `; 23 | export const listPolls = /* GraphQL */ ` 24 | query ListPolls( 25 | $filter: ModelPollFilterInput 26 | $limit: Int 27 | $nextToken: String 28 | ) { 29 | listPolls(filter: $filter, limit: $limit, nextToken: $nextToken) { 30 | items { 31 | id 32 | name 33 | type 34 | itemType 35 | createdAt 36 | candidates { 37 | items { 38 | pollCandidatesId 39 | image 40 | name 41 | upvotes 42 | } 43 | } 44 | } 45 | nextToken 46 | } 47 | } 48 | `; 49 | export const getCandidate = /* GraphQL */ ` 50 | query GetCandidate($id: ID!) { 51 | getCandidate(id: $id) { 52 | id 53 | pollCandidatesId 54 | image 55 | name 56 | upvotes 57 | } 58 | } 59 | `; 60 | export const listCandidates = /* GraphQL */ ` 61 | query ListCandidates( 62 | $filter: ModelCandidateFilterInput 63 | $limit: Int 64 | $nextToken: String 65 | ) { 66 | listCandidates(filter: $filter, limit: $limit, nextToken: $nextToken) { 67 | items { 68 | id 69 | pollCandidatesId 70 | image 71 | name 72 | upvotes 73 | } 74 | nextToken 75 | } 76 | } 77 | `; 78 | export const itemsByType = /* GraphQL */ ` 79 | query ItemsByType( 80 | $itemType: String 81 | $createdAt: ModelStringKeyConditionInput 82 | $sortDirection: ModelSortDirection 83 | $filter: ModelPollFilterInput 84 | $limit: Int 85 | $nextToken: String 86 | ) { 87 | itemsByType( 88 | itemType: $itemType 89 | createdAt: $createdAt 90 | sortDirection: $sortDirection 91 | filter: $filter 92 | limit: $limit 93 | nextToken: $nextToken 94 | ) { 95 | items { 96 | id 97 | name 98 | type 99 | itemType 100 | createdAt 101 | candidates { 102 | items { 103 | id 104 | pollCandidatesId 105 | image 106 | name 107 | upvotes 108 | } 109 | } 110 | } 111 | nextToken 112 | } 113 | } 114 | `; -------------------------------------------------------------------------------- /src/gql/subscriptions.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // this is an auto generated file. This will be overwritten 3 | 4 | export const onUpdateByID = /* GraphQL */ ` 5 | subscription OnUpdateById($id: ID!) { 6 | onUpdateByID(id: $id) { 7 | id 8 | clientId 9 | } 10 | } 11 | `; 12 | export const onCreatePoll = /* GraphQL */ ` 13 | subscription OnCreatePoll { 14 | onCreatePoll { 15 | id 16 | name 17 | type 18 | candidates { 19 | nextToken 20 | } 21 | itemType 22 | createdAt 23 | } 24 | } 25 | `; 26 | export const onUpdatePoll = /* GraphQL */ ` 27 | subscription OnUpdatePoll { 28 | onUpdatePoll { 29 | id 30 | name 31 | type 32 | candidates { 33 | nextToken 34 | } 35 | itemType 36 | createdAt 37 | } 38 | } 39 | `; 40 | export const onDeletePoll = /* GraphQL */ ` 41 | subscription OnDeletePoll { 42 | onDeletePoll { 43 | id 44 | name 45 | type 46 | candidates { 47 | nextToken 48 | } 49 | itemType 50 | createdAt 51 | } 52 | } 53 | `; 54 | export const onCreateCandidate = /* GraphQL */ ` 55 | subscription OnCreateCandidate { 56 | onCreateCandidate { 57 | id 58 | pollCandidatesId 59 | image 60 | name 61 | upvotes 62 | } 63 | } 64 | `; 65 | export const onUpdateCandidate = /* GraphQL */ ` 66 | subscription OnUpdateCandidate { 67 | onUpdateCandidate { 68 | id 69 | pollCandidatesId 70 | image 71 | name 72 | upvotes 73 | } 74 | } 75 | `; 76 | export const onDeleteCandidate = /* GraphQL */ ` 77 | subscription OnDeleteCandidate { 78 | onDeleteCandidate { 79 | id 80 | pollCandidatesId 81 | image 82 | name 83 | upvotes 84 | } 85 | } 86 | `; 87 | -------------------------------------------------------------------------------- /src/graphql/mutations.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // this is an auto generated file. This will be overwritten 3 | 4 | export const upVote = /* GraphQL */ ` 5 | mutation UpVote($id: ID, $clientId: ID) { 6 | upVote(id: $id, clientId: $clientId) { 7 | id 8 | clientId 9 | } 10 | } 11 | `; 12 | export const createPoll = /* GraphQL */ ` 13 | mutation CreatePoll( 14 | $input: CreatePollInput! 15 | $condition: ModelPollConditionInput 16 | ) { 17 | createPoll(input: $input, condition: $condition) { 18 | id 19 | name 20 | type 21 | candidates { 22 | nextToken 23 | } 24 | itemType 25 | createdAt 26 | } 27 | } 28 | `; 29 | export const updatePoll = /* GraphQL */ ` 30 | mutation UpdatePoll( 31 | $input: UpdatePollInput! 32 | $condition: ModelPollConditionInput 33 | ) { 34 | updatePoll(input: $input, condition: $condition) { 35 | id 36 | name 37 | type 38 | candidates { 39 | nextToken 40 | } 41 | itemType 42 | createdAt 43 | } 44 | } 45 | `; 46 | export const deletePoll = /* GraphQL */ ` 47 | mutation DeletePoll( 48 | $input: DeletePollInput! 49 | $condition: ModelPollConditionInput 50 | ) { 51 | deletePoll(input: $input, condition: $condition) { 52 | id 53 | name 54 | type 55 | candidates { 56 | nextToken 57 | } 58 | itemType 59 | createdAt 60 | } 61 | } 62 | `; 63 | export const createCandidate = /* GraphQL */ ` 64 | mutation CreateCandidate( 65 | $input: CreateCandidateInput! 66 | $condition: ModelCandidateConditionInput 67 | ) { 68 | createCandidate(input: $input, condition: $condition) { 69 | id 70 | pollCandidatesId 71 | image 72 | name 73 | upvotes 74 | } 75 | } 76 | `; 77 | export const updateCandidate = /* GraphQL */ ` 78 | mutation UpdateCandidate( 79 | $input: UpdateCandidateInput! 80 | $condition: ModelCandidateConditionInput 81 | ) { 82 | updateCandidate(input: $input, condition: $condition) { 83 | id 84 | pollCandidatesId 85 | image 86 | name 87 | upvotes 88 | } 89 | } 90 | `; 91 | export const deleteCandidate = /* GraphQL */ ` 92 | mutation DeleteCandidate( 93 | $input: DeleteCandidateInput! 94 | $condition: ModelCandidateConditionInput 95 | ) { 96 | deleteCandidate(input: $input, condition: $condition) { 97 | id 98 | pollCandidatesId 99 | image 100 | name 101 | upvotes 102 | } 103 | } 104 | `; 105 | -------------------------------------------------------------------------------- /src/graphql/queries.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // this is an auto generated file. This will be overwritten 3 | 4 | export const getPoll = /* GraphQL */ ` 5 | query GetPoll($id: ID!) { 6 | getPoll(id: $id) { 7 | id 8 | name 9 | type 10 | candidates { 11 | nextToken 12 | } 13 | itemType 14 | createdAt 15 | } 16 | } 17 | `; 18 | export const listPolls = /* GraphQL */ ` 19 | query ListPolls( 20 | $filter: ModelPollFilterInput 21 | $limit: Int 22 | $nextToken: String 23 | ) { 24 | listPolls(filter: $filter, limit: $limit, nextToken: $nextToken) { 25 | items { 26 | id 27 | name 28 | type 29 | itemType 30 | createdAt 31 | } 32 | nextToken 33 | } 34 | } 35 | `; 36 | export const getCandidate = /* GraphQL */ ` 37 | query GetCandidate($id: ID!) { 38 | getCandidate(id: $id) { 39 | id 40 | pollCandidatesId 41 | image 42 | name 43 | upvotes 44 | } 45 | } 46 | `; 47 | export const listCandidates = /* GraphQL */ ` 48 | query ListCandidates( 49 | $filter: ModelCandidateFilterInput 50 | $limit: Int 51 | $nextToken: String 52 | ) { 53 | listCandidates(filter: $filter, limit: $limit, nextToken: $nextToken) { 54 | items { 55 | id 56 | pollCandidatesId 57 | image 58 | name 59 | upvotes 60 | } 61 | nextToken 62 | } 63 | } 64 | `; 65 | export const itemsByType = /* GraphQL */ ` 66 | query ItemsByType( 67 | $itemType: String 68 | $createdAt: ModelStringKeyConditionInput 69 | $sortDirection: ModelSortDirection 70 | $filter: ModelPollFilterInput 71 | $limit: Int 72 | $nextToken: String 73 | ) { 74 | itemsByType( 75 | itemType: $itemType 76 | createdAt: $createdAt 77 | sortDirection: $sortDirection 78 | filter: $filter 79 | limit: $limit 80 | nextToken: $nextToken 81 | ) { 82 | items { 83 | id 84 | name 85 | type 86 | itemType 87 | createdAt 88 | } 89 | nextToken 90 | } 91 | } 92 | `; 93 | -------------------------------------------------------------------------------- /src/graphql/subscriptions.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // this is an auto generated file. This will be overwritten 3 | 4 | export const onUpdateById = /* GraphQL */ ` 5 | subscription OnUpdateById($id: ID!) { 6 | onUpdateByID(id: $id) { 7 | id 8 | clientId 9 | } 10 | } 11 | `; 12 | export const onCreatePoll = /* GraphQL */ ` 13 | subscription OnCreatePoll { 14 | onCreatePoll { 15 | id 16 | name 17 | type 18 | candidates { 19 | nextToken 20 | } 21 | itemType 22 | createdAt 23 | } 24 | } 25 | `; 26 | export const onUpdatePoll = /* GraphQL */ ` 27 | subscription OnUpdatePoll { 28 | onUpdatePoll { 29 | id 30 | name 31 | type 32 | candidates { 33 | nextToken 34 | } 35 | itemType 36 | createdAt 37 | } 38 | } 39 | `; 40 | export const onDeletePoll = /* GraphQL */ ` 41 | subscription OnDeletePoll { 42 | onDeletePoll { 43 | id 44 | name 45 | type 46 | candidates { 47 | nextToken 48 | } 49 | itemType 50 | createdAt 51 | } 52 | } 53 | `; 54 | export const onCreateCandidate = /* GraphQL */ ` 55 | subscription OnCreateCandidate { 56 | onCreateCandidate { 57 | id 58 | pollCandidatesId 59 | image 60 | name 61 | upvotes 62 | } 63 | } 64 | `; 65 | export const onUpdateCandidate = /* GraphQL */ ` 66 | subscription OnUpdateCandidate { 67 | onUpdateCandidate { 68 | id 69 | pollCandidatesId 70 | image 71 | name 72 | upvotes 73 | } 74 | } 75 | `; 76 | export const onDeleteCandidate = /* GraphQL */ ` 77 | subscription OnDeleteCandidate { 78 | onDeleteCandidate { 79 | id 80 | pollCandidatesId 81 | image 82 | name 83 | upvotes 84 | } 85 | } 86 | `; 87 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | backface-visibility: hidden; 5 | } 6 | 7 | html { 8 | scroll-behavior: smooth; 9 | } 10 | 11 | body { 12 | margin: 0; 13 | font-family: 'Montserrat', sans-serif; 14 | font-weight: 500; 15 | font-size: 14px; 16 | user-select: none; 17 | -webkit-tap-highlight-color: transparent; 18 | -webkit-touch-callout: none; 19 | -webkit-font-smoothing: antialiased; 20 | -moz-osx-font-smoothing: grayscale; 21 | } 22 | 23 | body * { 24 | outline: none; 25 | } 26 | 27 | body, 28 | .toast-background { 29 | color: #718096; 30 | background-color: #fff; 31 | } 32 | 33 | header { 34 | background-color: #fff; 35 | } 36 | 37 | .link { 38 | color: #4B5563 39 | } 40 | 41 | .link:hover { 42 | color: #1F2937 43 | } 44 | 45 | .highlight-text { 46 | color: #1F2937 47 | } 48 | 49 | .highlight-bg { 50 | background-color: #F3F4F6; 51 | } 52 | 53 | .highlight-border { 54 | border-color: #F3F4F6; 55 | } 56 | 57 | @media (prefers-color-scheme: dark) { 58 | 59 | body, 60 | .toast-background { 61 | color: #d0d6e0; 62 | background-color: #060606; 63 | } 64 | 65 | header { 66 | background-color: #060606; 67 | } 68 | 69 | .link { 70 | color: #95a2b3 71 | } 72 | 73 | .link:hover { 74 | color: #f7f8f8 75 | } 76 | 77 | .highlight-text { 78 | color: #f7f8f8 79 | } 80 | 81 | .highlight-bg { 82 | background-color: #0c0c0c; 83 | } 84 | 85 | .highlight-border { 86 | border-color: #0c0c0c; 87 | } 88 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import "./assets/main.css"; 4 | import "react-toastify/dist/ReactToastify.css"; 5 | import "./index.css"; 6 | import Router from "./Router"; 7 | import * as serviceWorker from "./serviceWorker"; 8 | 9 | import { CLIENT_ID, BASE_KEY } from "./utils/localStorageInfo"; 10 | 11 | import Amplify from "@aws-amplify/core"; 12 | import config from "./aws-exports"; 13 | Amplify.configure(config); 14 | 15 | const LOCAL_KEY = localStorage.getItem(BASE_KEY); 16 | 17 | if (!LOCAL_KEY) { 18 | localStorage.setItem(BASE_KEY, CLIENT_ID); 19 | } 20 | 21 | ReactDOM.render(, document.getElementById("root")); 22 | 23 | // If you want your app to work offline and load faster, you can change 24 | // unregister() to register() below. Note this comes with some pitfalls. 25 | // Learn more about service workers: https://bit.ly/CRA-PWA 26 | serviceWorker.unregister(); 27 | -------------------------------------------------------------------------------- /src/loading.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 19 | 20 | 21 | 28 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /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(({ headers, status }) => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = headers.get("content-type"); 109 | if ( 110 | status === 404 || 111 | (contentType != null && !contentType.includes("javascript")) 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(({ message }) => { 138 | console.error(message); 139 | }); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/utils/localStorageInfo.js: -------------------------------------------------------------------------------- 1 | import { v4 as uuid } from "uuid"; 2 | export const CLIENT_ID = `THIS_OR_THAT_CLIENT_KEY_${uuid()}`; 3 | export const BASE_KEY = "THIS_OR_THAT_CLIENT_KEY"; 4 | export const STORAGE_KEY = "THIS_OR_THAT_2020"; 5 | 6 | /* Using setVoteForPoll we are limiting each user to vote only 50 times per choice */ 7 | 8 | export function setVoteForPoll(pollId, candidateId) { 9 | let limitReached = false; 10 | let voteData = localStorage.getItem(STORAGE_KEY); 11 | voteData = JSON.parse(voteData); 12 | if (!voteData) { 13 | const voteData = { 14 | [pollId]: { 15 | [candidateId]: { 16 | upvotes: 1, 17 | }, 18 | }, 19 | }; 20 | localStorage.setItem(STORAGE_KEY, JSON.stringify(voteData)); 21 | } else { 22 | if (voteData[pollId]) { 23 | if (voteData[pollId][candidateId]) { 24 | if (voteData[pollId][candidateId].upvotes === 50) { 25 | limitReached = true; 26 | } else { 27 | voteData[pollId][candidateId].upvotes = 28 | voteData[pollId][candidateId].upvotes + 1; 29 | } 30 | } else { 31 | voteData[pollId][candidateId] = { 32 | upvotes: 1, 33 | }; 34 | } 35 | } else { 36 | voteData[pollId] = {}; 37 | voteData[pollId][candidateId] = { 38 | upvotes: 1, 39 | }; 40 | } 41 | localStorage.setItem(STORAGE_KEY, JSON.stringify(voteData)); 42 | } 43 | return limitReached; 44 | } 45 | -------------------------------------------------------------------------------- /src/utils/slugify.js: -------------------------------------------------------------------------------- 1 | function slugify(string) { 2 | const a = 3 | "àáâäæãåāăąçćčđďèéêëēėęěğǵḧîïíīįìłḿñńǹňôöòóœøōõőṕŕřßśšşșťțûüùúūǘůűųẃẍÿýžźż·/_,:;"; 4 | const b = 5 | "aaaaaaaaaacccddeeeeeeeegghiiiiiilmnnnnoooooooooprrsssssttuuuuuuuuuwxyyzzz------"; 6 | const p = new RegExp(a.split("").join("|"), "g"); 7 | 8 | return string 9 | .toString() 10 | .toLowerCase() 11 | .replace(/\s+/g, "-") // Replace spaces with - 12 | .replace(p, (c) => b.charAt(a.indexOf(c))) // Replace special characters 13 | .replace(/&/g, "-and-") // Replace & with 'and' 14 | .replace(/[^\w-]+/g, "") // Remove all non-word characters 15 | .replace(/--+/g, "-") // Replace multiple - with single - 16 | .replace(/^-+/, "") // Trim - from start of text 17 | .replace(/-+$/, ""); // Trim - from end of text 18 | } 19 | 20 | export default slugify; 21 | -------------------------------------------------------------------------------- /tailwind.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | prefix: "", 3 | important: false, 4 | separator: ":", 5 | theme: { 6 | screens: { 7 | xs3: "400px", 8 | xs2: "470px", 9 | xs1: "540px", 10 | sm: "640px", 11 | md: "768px", 12 | lg: "1024px", 13 | xl: "1280px", 14 | }, 15 | colors: { 16 | almostBlack: "#0b0e13", 17 | transparent: "transparent", 18 | current: "currentColor", 19 | mainPink: "#8B5CF6", 20 | mainBlue: "#3B82F6", 21 | 22 | black: "#000", 23 | white: "#fff", 24 | 25 | gray: { 26 | 100: "#f7fafc", 27 | 200: "#edf2f7", 28 | 300: "#e2e8f0", 29 | 400: "#cbd5e0", 30 | 500: "#a0aec0", 31 | 600: "#718096", 32 | 700: "#4a5568", 33 | 800: "#2d3748", 34 | 900: "#1a202c", 35 | }, 36 | red: { 37 | 100: "#fff5f5", 38 | 200: "#fed7d7", 39 | 300: "#feb2b2", 40 | 400: "#fc8181", 41 | 500: "#f56565", 42 | 600: "#e53e3e", 43 | 700: "#c53030", 44 | 800: "#9b2c2c", 45 | 900: "#742a2a", 46 | }, 47 | orange: { 48 | 100: "#fffaf0", 49 | 200: "#feebc8", 50 | 300: "#fbd38d", 51 | 400: "#f6ad55", 52 | 500: "#ed8936", 53 | 600: "#dd6b20", 54 | 700: "#c05621", 55 | 800: "#9c4221", 56 | 900: "#7b341e", 57 | }, 58 | yellow: { 59 | 100: "#fffff0", 60 | 200: "#fefcbf", 61 | 300: "#faf089", 62 | 400: "#f6e05e", 63 | 500: "#ecc94b", 64 | 600: "#d69e2e", 65 | 700: "#b7791f", 66 | 800: "#975a16", 67 | 900: "#744210", 68 | }, 69 | green: { 70 | 100: "#f0fff4", 71 | 200: "#c6f6d5", 72 | 300: "#9ae6b4", 73 | 400: "#68d391", 74 | 500: "#48bb78", 75 | 600: "#38a169", 76 | 700: "#2f855a", 77 | 800: "#276749", 78 | 900: "#22543d", 79 | }, 80 | teal: { 81 | 100: "#e6fffa", 82 | 200: "#b2f5ea", 83 | 300: "#81e6d9", 84 | 400: "#4fd1c5", 85 | 500: "#38b2ac", 86 | 600: "#319795", 87 | 700: "#2c7a7b", 88 | 800: "#285e61", 89 | 900: "#234e52", 90 | }, 91 | blue: { 92 | 100: "#ebf8ff", 93 | 200: "#bee3f8", 94 | 300: "#90cdf4", 95 | 400: "#63b3ed", 96 | 500: "#4299e1", 97 | 600: "#3182ce", 98 | 700: "#2b6cb0", 99 | 800: "#2c5282", 100 | 900: "#2a4365", 101 | }, 102 | indigo: { 103 | 100: "#ebf4ff", 104 | 200: "#c3dafe", 105 | 300: "#a3bffa", 106 | 400: "#7f9cf5", 107 | 500: "#667eea", 108 | 600: "#5a67d8", 109 | 700: "#4c51bf", 110 | 800: "#434190", 111 | 900: "#3c366b", 112 | }, 113 | purple: { 114 | 100: "#faf5ff", 115 | 200: "#e9d8fd", 116 | 300: "#d6bcfa", 117 | 400: "#b794f4", 118 | 500: "#9f7aea", 119 | 600: "#805ad5", 120 | 700: "#6b46c1", 121 | 800: "#553c9a", 122 | 900: "#44337a", 123 | }, 124 | pink: { 125 | 100: "#fff5f7", 126 | 200: "#fed7e2", 127 | 300: "#fbb6ce", 128 | 400: "#f687b3", 129 | 500: "#ed64a6", 130 | 600: "#d53f8c", 131 | 700: "#b83280", 132 | 800: "#97266d", 133 | 900: "#702459", 134 | }, 135 | }, 136 | spacing: { 137 | px: "1px", 138 | 0: "0", 139 | 1: "0.25rem", 140 | 2: "0.5rem", 141 | 3: "0.75rem", 142 | 4: "1rem", 143 | 5: "1.25rem", 144 | 6: "1.5rem", 145 | 8: "2rem", 146 | 10: "2.5rem", 147 | 12: "3rem", 148 | 14: "3.5rem", 149 | 16: "4rem", 150 | 18: "4.5rem", 151 | 20: "5rem", 152 | 24: "6rem", 153 | 32: "8rem", 154 | 40: "10rem", 155 | 48: "12rem", 156 | 56: "14rem", 157 | 58: "14.5rem", 158 | 60: "15rem", 159 | 64: "16rem", 160 | 66: "16.5rem", 161 | 68: "17rem", 162 | 72: "18rem", 163 | 76: "18.5rem", 164 | 80: "19rem", 165 | 84: "19.5rem", 166 | 88: "20rem", 167 | 92: "20.5rem", 168 | 96: "21rem", 169 | 100: "21.5rem", 170 | 104: "22rem", 171 | 108: "22.5rem", 172 | 112: "23rem", 173 | }, 174 | backgroundColor: (theme) => theme("colors"), 175 | backgroundPosition: { 176 | bottom: "bottom", 177 | center: "center", 178 | left: "left", 179 | "left-bottom": "left bottom", 180 | "left-top": "left top", 181 | right: "right", 182 | "right-bottom": "right bottom", 183 | "right-top": "right top", 184 | top: "top", 185 | }, 186 | backgroundSize: { 187 | auto: "auto", 188 | cover: "cover", 189 | contain: "contain", 190 | }, 191 | borderColor: (theme) => ({ 192 | ...theme("colors"), 193 | default: theme("colors.gray.300", "currentColor"), 194 | }), 195 | borderRadius: { 196 | none: "0", 197 | sm: "0.125rem", 198 | default: "0.25rem", 199 | md: "0.375rem", 200 | lg: "0.5rem", 201 | full: "9999px", 202 | }, 203 | borderWidth: { 204 | default: "1px", 205 | 0: "0", 206 | 2: "2px", 207 | 4: "4px", 208 | 8: "8px", 209 | }, 210 | boxShadow: { 211 | xs: "0 0 0 1px rgba(0, 0, 0, 0.05)", 212 | sm: "0 1px 2px 0 rgba(0, 0, 0, 0.05)", 213 | default: 214 | "0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)", 215 | md: 216 | "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)", 217 | lg: 218 | "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)", 219 | xl: 220 | "0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)", 221 | "2xl": "0 25px 50px -12px rgba(0, 0, 0, 0.25)", 222 | inner: "inset 0 2px 4px 0 rgba(0, 0, 0, 0.06)", 223 | outline: "0 0 0 3px rgba(66, 153, 225, 0.5)", 224 | none: "none", 225 | button: "rgba(0, 0, 0, 0.25) 0px 0.125rem 0.25rem", 226 | }, 227 | container: {}, 228 | cursor: { 229 | auto: "auto", 230 | default: "default", 231 | pointer: "pointer", 232 | wait: "wait", 233 | text: "text", 234 | move: "move", 235 | "not-allowed": "not-allowed", 236 | }, 237 | divideColor: (theme) => theme("borderColor"), 238 | divideWidth: (theme) => theme("borderWidth"), 239 | fill: { 240 | current: "currentColor", 241 | }, 242 | flex: { 243 | 1: "1 1 0%", 244 | auto: "1 1 auto", 245 | initial: "0 1 auto", 246 | none: "none", 247 | }, 248 | flexGrow: { 249 | 0: "0", 250 | default: "1", 251 | }, 252 | flexShrink: { 253 | 0: "0", 254 | default: "1", 255 | }, 256 | fontFamily: { 257 | sans: [ 258 | "system-ui", 259 | "-apple-system", 260 | "BlinkMacSystemFont", 261 | '"Segoe UI"', 262 | "Roboto", 263 | '"Helvetica Neue"', 264 | "Arial", 265 | '"Noto Sans"', 266 | "sans-serif", 267 | '"Apple Color Emoji"', 268 | '"Segoe UI Emoji"', 269 | '"Segoe UI Symbol"', 270 | '"Noto Color Emoji"', 271 | ], 272 | serif: ["Georgia", "Cambria", '"Times New Roman"', "Times", "serif"], 273 | mono: [ 274 | "Menlo", 275 | "Monaco", 276 | "Consolas", 277 | '"Liberation Mono"', 278 | '"Courier New"', 279 | "monospace", 280 | ], 281 | main: ["Montserrat", "sans-serif"], 282 | }, 283 | fontSize: { 284 | xs: "0.75rem", 285 | sm: "0.875rem", 286 | base: "1rem", 287 | lg: "1.125rem", 288 | xl: "1.25rem", 289 | "2xl": "1.5rem", 290 | "3xl": "1.875rem", 291 | "4xl": "2.25rem", 292 | "5xl": "3rem", 293 | "6xl": "4rem", 294 | }, 295 | fontWeight: { 296 | hairline: "100", 297 | thin: "200", 298 | light: "300", 299 | normal: "400", 300 | medium: "500", 301 | semibold: "600", 302 | bold: "700", 303 | extrabold: "800", 304 | black: "900", 305 | }, 306 | height: (theme) => ({ 307 | auto: "auto", 308 | ...theme("spacing"), 309 | full: "100%", 310 | screen: "100vh", 311 | }), 312 | inset: { 313 | 0: "0", 314 | auto: "auto", 315 | }, 316 | letterSpacing: { 317 | tighter: "-0.05em", 318 | tight: "-0.025em", 319 | normal: "0", 320 | wide: "0.025em", 321 | wider: "0.05em", 322 | widest: "0.1em", 323 | }, 324 | lineHeight: { 325 | none: "1", 326 | tight: "1.25", 327 | snug: "1.375", 328 | normal: "1.5", 329 | relaxed: "1.625", 330 | loose: "2", 331 | 3: ".75rem", 332 | 4: "1rem", 333 | 5: "1.25rem", 334 | 6: "1.5rem", 335 | 7: "1.75rem", 336 | 8: "2rem", 337 | 9: "2.25rem", 338 | 10: "2.5rem", 339 | }, 340 | listStyleType: { 341 | none: "none", 342 | disc: "disc", 343 | decimal: "decimal", 344 | }, 345 | margin: (theme, { negative }) => ({ 346 | auto: "auto", 347 | ...theme("spacing"), 348 | ...negative(theme("spacing")), 349 | }), 350 | maxHeight: { 351 | full: "100%", 352 | screen: "100vh", 353 | }, 354 | maxWidth: (theme, { breakpoints }) => ({ 355 | none: "none", 356 | xs: "20rem", 357 | sm: "24rem", 358 | md: "28rem", 359 | lg: "32rem", 360 | xl: "36rem", 361 | "2xl": "42rem", 362 | "3xl": "48rem", 363 | "4xl": "56rem", 364 | "5xl": "64rem", 365 | "6xl": "72rem", 366 | full: "100%", 367 | ...breakpoints(theme("screens")), 368 | }), 369 | minHeight: (theme) => ({ 370 | ...theme("spacing"), 371 | 0: "0", 372 | full: "100%", 373 | screen: "100vh", 374 | }), 375 | minWidth: (theme) => ({ 376 | ...theme("spacing"), 377 | 0: "0", 378 | full: "100%", 379 | }), 380 | objectPosition: { 381 | bottom: "bottom", 382 | center: "center", 383 | left: "left", 384 | "left-bottom": "left bottom", 385 | "left-top": "left top", 386 | right: "right", 387 | "right-bottom": "right bottom", 388 | "right-top": "right top", 389 | top: "top", 390 | }, 391 | opacity: { 392 | 0: "0", 393 | 25: "0.25", 394 | 50: "0.5", 395 | 75: "0.75", 396 | 100: "1", 397 | }, 398 | order: { 399 | first: "-9999", 400 | last: "9999", 401 | none: "0", 402 | 1: "1", 403 | 2: "2", 404 | 3: "3", 405 | 4: "4", 406 | 5: "5", 407 | 6: "6", 408 | 7: "7", 409 | 8: "8", 410 | 9: "9", 411 | 10: "10", 412 | 11: "11", 413 | 12: "12", 414 | }, 415 | padding: (theme) => theme("spacing"), 416 | placeholderColor: (theme) => theme("colors"), 417 | space: (theme, { negative }) => ({ 418 | ...theme("spacing"), 419 | ...negative(theme("spacing")), 420 | }), 421 | stroke: { 422 | current: "currentColor", 423 | }, 424 | strokeWidth: { 425 | 0: "0", 426 | 1: "1", 427 | 2: "2", 428 | }, 429 | textColor: (theme) => theme("colors"), 430 | width: (theme) => ({ 431 | auto: "auto", 432 | ...theme("spacing"), 433 | "1/2": "50%", 434 | "1/3": "33.333333%", 435 | "2/3": "66.666667%", 436 | "1/4": "25%", 437 | "2/4": "50%", 438 | "3/4": "75%", 439 | "1/5": "20%", 440 | "2/5": "40%", 441 | "3/5": "60%", 442 | "4/5": "80%", 443 | "1/6": "16.666667%", 444 | "2/6": "33.333333%", 445 | "3/6": "50%", 446 | "4/6": "66.666667%", 447 | "5/6": "83.333333%", 448 | "1/12": "8.333333%", 449 | "2/12": "16.666667%", 450 | "3/12": "25%", 451 | "4/12": "33.333333%", 452 | "5/12": "41.666667%", 453 | "6/12": "50%", 454 | "7/12": "58.333333%", 455 | "8/12": "66.666667%", 456 | "9/12": "75%", 457 | "10/12": "83.333333%", 458 | "11/12": "91.666667%", 459 | full: "100%", 460 | screen: "100vw", 461 | main: "940px", 462 | }), 463 | zIndex: { 464 | auto: "auto", 465 | 0: "0", 466 | 10: "10", 467 | 20: "20", 468 | 30: "30", 469 | 40: "40", 470 | 50: "50", 471 | }, 472 | gap: (theme) => theme("spacing"), 473 | gridTemplateColumns: { 474 | none: "none", 475 | 1: "repeat(1, minmax(0, 1fr))", 476 | 2: "repeat(2, minmax(0, 1fr))", 477 | 3: "repeat(3, minmax(0, 1fr))", 478 | 4: "repeat(4, minmax(0, 1fr))", 479 | 5: "repeat(5, minmax(0, 1fr))", 480 | 6: "repeat(6, minmax(0, 1fr))", 481 | 7: "repeat(7, minmax(0, 1fr))", 482 | 8: "repeat(8, minmax(0, 1fr))", 483 | 9: "repeat(9, minmax(0, 1fr))", 484 | 10: "repeat(10, minmax(0, 1fr))", 485 | 11: "repeat(11, minmax(0, 1fr))", 486 | 12: "repeat(12, minmax(0, 1fr))", 487 | }, 488 | gridColumn: { 489 | auto: "auto", 490 | "span-1": "span 1 / span 1", 491 | "span-2": "span 2 / span 2", 492 | "span-3": "span 3 / span 3", 493 | "span-4": "span 4 / span 4", 494 | "span-5": "span 5 / span 5", 495 | "span-6": "span 6 / span 6", 496 | "span-7": "span 7 / span 7", 497 | "span-8": "span 8 / span 8", 498 | "span-9": "span 9 / span 9", 499 | "span-10": "span 10 / span 10", 500 | "span-11": "span 11 / span 11", 501 | "span-12": "span 12 / span 12", 502 | }, 503 | gridColumnStart: { 504 | auto: "auto", 505 | 1: "1", 506 | 2: "2", 507 | 3: "3", 508 | 4: "4", 509 | 5: "5", 510 | 6: "6", 511 | 7: "7", 512 | 8: "8", 513 | 9: "9", 514 | 10: "10", 515 | 11: "11", 516 | 12: "12", 517 | 13: "13", 518 | }, 519 | gridColumnEnd: { 520 | auto: "auto", 521 | 1: "1", 522 | 2: "2", 523 | 3: "3", 524 | 4: "4", 525 | 5: "5", 526 | 6: "6", 527 | 7: "7", 528 | 8: "8", 529 | 9: "9", 530 | 10: "10", 531 | 11: "11", 532 | 12: "12", 533 | 13: "13", 534 | }, 535 | gridTemplateRows: { 536 | none: "none", 537 | 1: "repeat(1, minmax(0, 1fr))", 538 | 2: "repeat(2, minmax(0, 1fr))", 539 | 3: "repeat(3, minmax(0, 1fr))", 540 | 4: "repeat(4, minmax(0, 1fr))", 541 | 5: "repeat(5, minmax(0, 1fr))", 542 | 6: "repeat(6, minmax(0, 1fr))", 543 | }, 544 | gridRow: { 545 | auto: "auto", 546 | "span-1": "span 1 / span 1", 547 | "span-2": "span 2 / span 2", 548 | "span-3": "span 3 / span 3", 549 | "span-4": "span 4 / span 4", 550 | "span-5": "span 5 / span 5", 551 | "span-6": "span 6 / span 6", 552 | }, 553 | gridRowStart: { 554 | auto: "auto", 555 | 1: "1", 556 | 2: "2", 557 | 3: "3", 558 | 4: "4", 559 | 5: "5", 560 | 6: "6", 561 | 7: "7", 562 | }, 563 | gridRowEnd: { 564 | auto: "auto", 565 | 1: "1", 566 | 2: "2", 567 | 3: "3", 568 | 4: "4", 569 | 5: "5", 570 | 6: "6", 571 | 7: "7", 572 | }, 573 | transformOrigin: { 574 | center: "center", 575 | top: "top", 576 | "top-right": "top right", 577 | right: "right", 578 | "bottom-right": "bottom right", 579 | bottom: "bottom", 580 | "bottom-left": "bottom left", 581 | left: "left", 582 | "top-left": "top left", 583 | }, 584 | scale: { 585 | 0: "0", 586 | 50: ".5", 587 | 75: ".75", 588 | 90: ".9", 589 | 95: ".95", 590 | 100: "1", 591 | 105: "1.05", 592 | 110: "1.1", 593 | 125: "1.25", 594 | 150: "1.5", 595 | }, 596 | rotate: { 597 | "-180": "-180deg", 598 | "-90": "-90deg", 599 | "-45": "-45deg", 600 | 0: "0", 601 | 45: "45deg", 602 | 90: "90deg", 603 | 180: "180deg", 604 | }, 605 | translate: (theme, { negative }) => ({ 606 | ...theme("spacing"), 607 | ...negative(theme("spacing")), 608 | "-full": "-100%", 609 | "-1/2": "-50%", 610 | "1/2": "50%", 611 | full: "100%", 612 | }), 613 | skew: { 614 | "-12": "-12deg", 615 | "-6": "-6deg", 616 | "-3": "-3deg", 617 | 0: "0", 618 | 3: "3deg", 619 | 6: "6deg", 620 | 12: "12deg", 621 | }, 622 | transitionProperty: { 623 | none: "none", 624 | all: "all", 625 | default: 626 | "background-color, border-color, color, fill, stroke, opacity, box-shadow, transform", 627 | colors: "background-color, border-color, color, fill, stroke", 628 | opacity: "opacity", 629 | shadow: "box-shadow", 630 | transform: "transform", 631 | }, 632 | transitionTimingFunction: { 633 | linear: "linear", 634 | in: "cubic-bezier(0.4, 0, 1, 1)", 635 | out: "cubic-bezier(0, 0, 0.2, 1)", 636 | "in-out": "cubic-bezier(0.4, 0, 0.2, 1)", 637 | }, 638 | transitionDuration: { 639 | 75: "75ms", 640 | 100: "100ms", 641 | 150: "150ms", 642 | 200: "200ms", 643 | 300: "300ms", 644 | 500: "500ms", 645 | 700: "700ms", 646 | 1000: "1000ms", 647 | }, 648 | transitionDelay: { 649 | 75: "75ms", 650 | 100: "100ms", 651 | 150: "150ms", 652 | 200: "200ms", 653 | 300: "300ms", 654 | 500: "500ms", 655 | 700: "700ms", 656 | 1000: "1000ms", 657 | }, 658 | }, 659 | variants: { 660 | accessibility: ["responsive", "focus"], 661 | alignContent: ["responsive"], 662 | alignItems: ["responsive"], 663 | alignSelf: ["responsive"], 664 | appearance: ["responsive"], 665 | backgroundAttachment: ["responsive"], 666 | backgroundColor: ["responsive", "hover", "focus"], 667 | backgroundPosition: ["responsive"], 668 | backgroundRepeat: ["responsive"], 669 | backgroundSize: ["responsive"], 670 | borderCollapse: ["responsive"], 671 | borderColor: ["responsive", "hover", "focus"], 672 | borderRadius: ["responsive"], 673 | borderStyle: ["responsive"], 674 | borderWidth: ["responsive"], 675 | boxShadow: ["responsive", "hover", "focus"], 676 | boxSizing: ["responsive"], 677 | cursor: ["responsive"], 678 | display: ["responsive"], 679 | divideColor: ["responsive"], 680 | divideWidth: ["responsive"], 681 | fill: ["responsive"], 682 | flex: ["responsive"], 683 | flexDirection: ["responsive"], 684 | flexGrow: ["responsive"], 685 | flexShrink: ["responsive"], 686 | flexWrap: ["responsive"], 687 | float: ["responsive"], 688 | clear: ["responsive"], 689 | fontFamily: ["responsive"], 690 | fontSize: ["responsive"], 691 | fontSmoothing: ["responsive"], 692 | fontStyle: ["responsive"], 693 | fontWeight: ["responsive", "hover", "focus"], 694 | height: ["responsive"], 695 | inset: ["responsive"], 696 | justifyContent: ["responsive"], 697 | letterSpacing: ["responsive"], 698 | lineHeight: ["responsive"], 699 | listStylePosition: ["responsive"], 700 | listStyleType: ["responsive"], 701 | margin: ["responsive"], 702 | maxHeight: ["responsive"], 703 | maxWidth: ["responsive"], 704 | minHeight: ["responsive"], 705 | minWidth: ["responsive"], 706 | objectFit: ["responsive"], 707 | objectPosition: ["responsive"], 708 | opacity: ["responsive", "hover", "focus"], 709 | order: ["responsive"], 710 | outline: ["responsive", "focus"], 711 | overflow: ["responsive"], 712 | padding: ["responsive"], 713 | placeholderColor: ["responsive", "focus"], 714 | pointerEvents: ["responsive"], 715 | position: ["responsive"], 716 | resize: ["responsive"], 717 | space: ["responsive"], 718 | stroke: ["responsive"], 719 | strokeWidth: ["responsive"], 720 | tableLayout: ["responsive"], 721 | textAlign: ["responsive"], 722 | textColor: ["responsive", "hover", "focus"], 723 | textDecoration: ["responsive", "hover", "focus"], 724 | textTransform: ["responsive"], 725 | userSelect: ["responsive"], 726 | verticalAlign: ["responsive"], 727 | visibility: ["responsive"], 728 | whitespace: ["responsive"], 729 | width: ["responsive"], 730 | wordBreak: ["responsive"], 731 | zIndex: ["responsive"], 732 | gap: ["responsive"], 733 | gridAutoFlow: ["responsive"], 734 | gridTemplateColumns: ["responsive"], 735 | gridColumn: ["responsive"], 736 | gridColumnStart: ["responsive"], 737 | gridColumnEnd: ["responsive"], 738 | gridTemplateRows: ["responsive"], 739 | gridRow: ["responsive"], 740 | gridRowStart: ["responsive"], 741 | gridRowEnd: ["responsive"], 742 | transform: ["responsive"], 743 | transformOrigin: ["responsive"], 744 | scale: ["responsive", "hover", "focus"], 745 | rotate: ["responsive", "hover", "focus"], 746 | translate: ["responsive", "hover", "focus"], 747 | skew: ["responsive", "hover", "focus"], 748 | transitionProperty: ["responsive"], 749 | transitionTimingFunction: ["responsive"], 750 | transitionDuration: ["responsive"], 751 | transitionDelay: ["responsive"], 752 | }, 753 | corePlugins: {}, 754 | plugins: [], 755 | }; 756 | --------------------------------------------------------------------------------