├── .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 |
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 | [](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 | You need to enable JavaScript to run this app.
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 |
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 |
15 | {title}
16 |
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 |
65 |
66 |
70 | {candidate.upvotes}
71 |
72 |
73 |
74 |
{candidate.name}
75 |
76 |
77 | );
78 | }
79 | return (
80 |
81 |
82 |
88 |
89 |
90 |
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 |
108 | )
109 | }
110 | {pollView && (
111 |
112 | {
115 | const url = window.location.href;
116 | navigator.clipboard.writeText(url);
117 | toast("Successfully copied to clipboard!", {
118 | className: "toast-background",
119 | });
120 | }}
121 | />
122 |
123 |
124 | )}
125 |
126 | );
127 | }
128 |
129 | function ImageVoteBlock({ index, candidate, poll, onUpVote }) {
130 | return (
131 |
132 |
onUpVote(candidate, poll)}
136 | >
137 |
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 | setPollType("text")}
142 | title="Text"
143 | backgroundColor="#00acee"
144 | />
145 | setPollType("image")}
147 | title="Image"
148 | backgroundColor="#00acee"
149 | />
150 |
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 |
198 | )}
199 |
200 |
207 |
212 | Choose a file
213 |
214 |
215 |
Down vote image
216 | {state.candidate2 && (
217 |
222 | )}
223 |
224 |
231 |
236 | Choose a file
237 |
238 |
239 |
240 |
241 | )}
242 | {isUploading && (
243 |
244 |
245 | Uploading
246 |
247 | )}
248 | {pollType && (
249 |
250 |
256 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------