├── .babelrc ├── .eslintrc ├── .gitignore ├── LICENSE ├── README.md ├── index.html ├── package.json ├── server.js ├── src ├── HandsUp.schema ├── app.js ├── client.js ├── components │ ├── AddQuestion.js │ ├── HandsUpApp.js │ ├── Loading.js │ ├── ModeratorOptions.js │ ├── Profile.js │ ├── Question.js │ ├── QuestionList.js │ ├── TopNavigation.js │ ├── TweetParser.js │ └── Votes.js ├── graphql │ ├── CreateQuestion.mutation.gql │ ├── CreateUser.mutation.gql │ ├── FlagQuestion.mutation.gql │ ├── FlagUser.mutation.gql │ ├── Question.fragment.gql │ ├── Questions.query.gql │ ├── Questions.subscription.gql │ ├── User.query.gql │ ├── Vote.mutation.gql │ └── Vote.query.gql ├── images │ ├── handsup.gif │ └── partyparrot.png ├── index.html ├── routes.js ├── services │ └── Authorisation.js ├── style.css └── utils │ └── helpers.js ├── webpack.config.js └── webpack.dist.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react", "es2015", "stage-0"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": [ 4 | "standard", 5 | "standard-react" 6 | ], 7 | "env": { 8 | "browser": true 9 | }, 10 | "rules": { 11 | "react/prop-types": "off", 12 | "no-debugger": "off", 13 | "semi": [2, "never"], 14 | "comma-dangle": [2, "always-multiline"], 15 | "space-before-function-paren": ["error", "never"], 16 | "space-infix-ops": 0, 17 | "max-len": [2, 200, 2], 18 | "react/jsx-no-bind": [1, { 19 | "allowArrowFunctions": true 20 | }], 21 | "jsx-quotes": [2, "prefer-single"] 22 | }, 23 | "globals": { 24 | "gql": true 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | npm-debug.log 3 | data/schema.graphql 4 | node_modules 5 | yarn.lock 6 | dist 7 | .firebaserc 8 | firebase.json 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Gerard Sans 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 | # handsup-react 2 | 3 | 4 | 5 | [Building HandsUp: an OS real-time Q&A App using GraphQL and React](https://medium.com/@gerard.sans/building-handsup-an-os-real-time-voting-app-using-graphql-and-react-b2b7dcd0e136) 6 | 7 | ## HandsUp 🙌 8 | Make your events more interactive allowing attendees to participate by adding questions and voting using their phone or laptop. 9 | 10 | Organisers and speakers can use it to answer questions and run Q&A or panels sessions. 11 | 12 | ## Technology stack 13 | 14 | This application integrates the following technologies: 15 | - [Auth0](http://auth0.com) to authenticate users using their social profiles (Google, Twitter) 16 | - [Apollo Client](http://dev.apollodata.com) to communicate with GraphQL Server 17 | - [graphcool](http://graph.cool) providing the GraphQL Server 18 | 19 | ## Usage 20 | 21 | Log in using your social account to be able to add new questions. In order to vote click on the heart button besides each question. 22 | 23 | ## Development 24 | 25 | If you have any questions feel free to ping me on [@gerardsans](http://twitter.com/gerardsans). 26 | 27 | ### Install 28 | 29 | First, clone the repo via git: 30 | 31 | ```bash 32 | $ git clone https://github.com/gsans/handsup-react.git 33 | ``` 34 | 35 | And then install dependencies: 36 | 37 | ```bash 38 | $ cd handsup-react && yarn 39 | ``` 40 | 41 | ### Run 42 | ```bash 43 | $ yarn run dev 44 | ``` 45 | 46 | > Note: requires a node version >=6.x 47 | 48 | ## Getting Started 49 | 50 | In order to run this project you need to create the data model (schema) below using [graphcool](http://graph.cool) console online and setup Auth0. 51 | 52 | ### graphcool - HandsUp Schema 53 | 54 | This is the schema used 55 | 56 | ```graphql 57 | type Question @model { 58 | id: ID! @isUnique 59 | body: String! 60 | votes: [Vote!]! @relation(name: "VoteOnQuestion") 61 | user: User @relation(name: "UserOnQuestion") 62 | createdAt: DateTime! 63 | updatedAt: DateTime! 64 | } 65 | 66 | type Vote @model { 67 | id: ID! @isUnique 68 | question: Question @relation(name: "VoteOnQuestion") 69 | createdAt: DateTime! 70 | updatedAt: DateTime! 71 | } 72 | 73 | type User @model { 74 | auth0UserId: String 75 | id: ID! @isUnique 76 | name: String 77 | username: String 78 | pictureUrl: String 79 | questions: [Question!]! @relation(name: "UserOnQuestion") 80 | role: USER_ROLE 81 | createdAt: DateTime! 82 | updatedAt: DateTime! 83 | } 84 | 85 | enum USER_ROLE { 86 | Admin 87 | Organiser 88 | Moderator 89 | User 90 | } 91 | ``` 92 | 93 | You can read the following blog as reference for an example as how you would create a schema from scratch 94 | - [Setting up a GraphQL backend in 5 minutes](https://www.graph.cool/docs/tutorials/quickstart-1-thaeghi8ro) 95 | 96 | 97 | ### Auth0 + graphcool setup 98 | 99 | In order to use Auth0 you need to do few steps. You can find some assistance by reading the articles below. 100 | 101 | - [User Authentication with Auth0 for React and Apollo](https://www.graph.cool/docs/tutorials/react-apollo-auth0-pheiph4ooj) 102 | - [Auth0 - React Getting Started](https://auth0.com/docs/quickstart/spa/react/00-getting-started) 103 | - [Connect your app to Google](https://auth0.com/docs/connections/social/google) 104 | - [Connect your app to Twitter](https://auth0.com/docs/connections/social/twitter) 105 | 106 | 107 | 108 | ## License 109 | MIT © [Gerard Sans](https://github.com/gsans) -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Apollo Client • TodoMVC 8 | 9 | 10 | 11 | 12 | 13 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "start": "NODE_ENV=production webpack --progress && NODE_ENV=production node server.js", 5 | "dev": "webpack-dev-server -d --hot --inline --no-info --port 3000", 6 | "build": "webpack --progress -p --config=webpack.dist.config.js" 7 | }, 8 | "dependencies": { 9 | "apollo-client": "1.0.1", 10 | "auth0-js": "8.5.0", 11 | "auth0-lock": "10.14.0", 12 | "classnames": "2.2.5", 13 | "eslint-plugin-react": "6.6.0", 14 | "extract-text-webpack-plugin": "2.1.0", 15 | "graphql-tag": "^1.3.1", 16 | "immutability-helper": "2.1.2", 17 | "jwt-decode": "2.2.0", 18 | "prop-types": "15.5.8", 19 | "react": "15.4.2", 20 | "react-apollo": "1.0.0", 21 | "react-dom": "15.4.2", 22 | "react-native": "0.41.2", 23 | "react-redux": "5.0.3", 24 | "react-router": "4.0.0", 25 | "react-router-dom": "4.0.0", 26 | "react-s-alert": "1.3.0", 27 | "react-timeago": "3.2.0", 28 | "redux": "^3.6.0", 29 | "redux-thunk": "2.2.0", 30 | "smoothscroll": "0.3.0", 31 | "smoothscroll-polyfill": "^0.3.5", 32 | "subscriptions-transport-ws": "0.5.5", 33 | "webpack": "2.4.1", 34 | "whatwg-fetch": "^1.0.0" 35 | }, 36 | "devDependencies": { 37 | "babel-cli": "^6.10.1", 38 | "babel-core": "^6.10.4", 39 | "babel-eslint": "^6.1.2", 40 | "babel-loader": "^6.2.4", 41 | "babel-plugin-stylus-compiler": "1.4.0", 42 | "babel-plugin-transform-css-import-to-string": "0.0.2", 43 | "babel-plugin-version-inline": "1.0.0", 44 | "babel-preset-es2015": "^6.9.0", 45 | "babel-preset-react": "^6.11.1", 46 | "babel-preset-stage-0": "6.5.0", 47 | "css-loader": "0.23.1", 48 | "eslint": "3.0.1", 49 | "eslint-config-standard": "^5.3.5", 50 | "eslint-config-standard-react": "^3.0.0", 51 | "eslint-loader": "^1.4.1", 52 | "eslint-plugin-babel": "^3.3.0", 53 | "eslint-plugin-promise": "^2.0.0", 54 | "eslint-plugin-standard": "^2.0.0", 55 | "extract-text-webpack-plugin": "2.1.0", 56 | "file-loader": "0.11.1", 57 | "html-webpack-plugin": "^2.22.0", 58 | "koa": "2.2.0", 59 | "koa-convert": "1.2.0", 60 | "koa-static": "3.0.0", 61 | "style-loader": "^0.13.1", 62 | "webpack": "2.4.1", 63 | "webpack-dev-server": "1.14.1" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const port = process.env.PORT || 3000; 4 | const Koa = require('koa'); 5 | const serve = require('koa-static'); 6 | const convert = require('koa-convert'); 7 | const app = new Koa(); 8 | const _use = app.use; 9 | app.use = (x) => _use.call(app, convert(x)); 10 | app.use(serve('./dist')); 11 | 12 | const server = app.listen(port, function () { 13 | let host = server.address().address; 14 | let port = server.address().port; 15 | console.log('listening at http://%s:%s', host, port); 16 | }); -------------------------------------------------------------------------------- /src/HandsUp.schema: -------------------------------------------------------------------------------- 1 | type File @model { 2 | contentType: String! 3 | createdAt: DateTime! 4 | id: ID! @isUnique 5 | name: String! 6 | secret: String! @isUnique 7 | size: Int! 8 | updatedAt: DateTime! 9 | url: String! @isUnique 10 | } 11 | 12 | type Question @model { 13 | body: String! 14 | createdAt: DateTime! 15 | flagged: Boolean 16 | id: ID! @isUnique 17 | updatedAt: DateTime! 18 | user: User @relation(name: "UserOnQuestion") 19 | votes: [Vote!]! @relation(name: "VoteOnQuestion") 20 | } 21 | 22 | type User @model { 23 | auth0UserId: String @isUnique 24 | createdAt: DateTime! 25 | flagged: Boolean 26 | id: ID! @isUnique 27 | name: String 28 | pictureUrl: String 29 | questions: [Question!]! @relation(name: "UserOnQuestion") 30 | role: USER_ROLE 31 | updatedAt: DateTime! 32 | username: String 33 | } 34 | 35 | type Vote @model { 36 | createdAt: DateTime! 37 | id: ID! @isUnique 38 | question: Question @relation(name: "VoteOnQuestion") 39 | updatedAt: DateTime! 40 | } 41 | 42 | enum USER_ROLE { 43 | Admin 44 | Organiser 45 | Moderator 46 | User 47 | } -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from 'react-dom' 3 | import HandsUpApp from './components/HandsUpApp' 4 | 5 | import { ApolloProvider } from 'react-apollo' 6 | import { client } from './client' 7 | import { createStore, combineReducers, applyMiddleware, compose } from 'redux' 8 | import { HashRouter, Route } from 'react-router-dom' 9 | import Authorisation from './services/Authorisation' 10 | import './style.css' 11 | import 'react-s-alert/dist/s-alert-default.css' 12 | import 'react-s-alert/dist/s-alert-css-effects/slide.css' 13 | 14 | const combinedReducer = combineReducers({ 15 | apollo: client.reducer(), 16 | }) 17 | 18 | const store = compose( 19 | applyMiddleware( 20 | client.middleware(), 21 | ), 22 | window.devToolsExtension ? window.devToolsExtension() : f => f 23 | )(createStore)(combinedReducer) 24 | 25 | const auth = new Authorisation() 26 | 27 | class HandsUpAppWrapper extends React.Component { 28 | render() { 29 | return ( 30 | 31 | ) 32 | } 33 | } 34 | 35 | render( 36 | 37 | 38 | 39 | 40 | , 41 | document.getElementById('root') 42 | ) 43 | -------------------------------------------------------------------------------- /src/client.js: -------------------------------------------------------------------------------- 1 | import ApolloClient, { createNetworkInterface } from 'apollo-client' 2 | import { SubscriptionClient, addGraphQLSubscriptions } from 'subscriptions-transport-ws' 3 | 4 | const wsClient = new SubscriptionClient('wss://subscriptions.graph.cool/v1/__ENTER_YOUR_KEY__', { 5 | reconnect: true, 6 | /* connectionParams: { 7 | authToken: user.authToken, 8 | }, */ 9 | }) 10 | 11 | const networkInterface = createNetworkInterface({ 12 | uri: 'https://api.graph.cool/simple/v1/__ENTER_YOUR_KEY__', 13 | dataIdFromObject: record => record.id, 14 | }) 15 | 16 | networkInterface.use([{ 17 | applyMiddleware(req, next) { 18 | if (localStorage.getItem('auth0IdToken')) { 19 | if (!req.options.headers) { 20 | req.options.headers = {} 21 | } 22 | req.options.headers.authorization = 23 | `Bearer ${localStorage.getItem('auth0IdToken')}` 24 | } 25 | next() 26 | }, 27 | }]) 28 | 29 | const networkInterfaceWithSubscriptions = addGraphQLSubscriptions( 30 | networkInterface, 31 | wsClient 32 | ) 33 | 34 | export const client = new ApolloClient({ 35 | networkInterface: networkInterfaceWithSubscriptions, 36 | }) 37 | -------------------------------------------------------------------------------- /src/components/AddQuestion.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { graphql } from 'react-apollo' 4 | import update from 'immutability-helper' 5 | import { addToLocalCache, isDuplicate, ALERT_DEFAULT } from '../utils/helpers' 6 | require('smoothscroll-polyfill').polyfill() 7 | import Alert from 'react-s-alert' 8 | 9 | import CREATE_QUESTION_MUTATION from '../graphql/CreateQuestion.mutation.gql' 10 | const MAX_CHAR = 140 11 | 12 | class AddQuestion extends React.Component { 13 | 14 | constructor(props) { 15 | super(props) 16 | this.state = { 17 | chars_left: MAX_CHAR, 18 | } 19 | } 20 | 21 | onSubmit(event) { 22 | event.preventDefault() 23 | if (!this.input.value || this.input.value.length === 0) { 24 | return 25 | } 26 | if (scrollTo) { 27 | // let elem = document.getElementById('app') 28 | // elem.scrollTop = elem.scrollHeight 29 | let elem = document.querySelector('#bottom') 30 | elem.scrollIntoView({ behavior: 'smooth' }) 31 | } 32 | this.props 33 | .addQuestion(this.input.value, this.props.auth.userId) 34 | .then(() => { 35 | Alert.info('Question sent! ✨🚀', ALERT_DEFAULT) 36 | this.input.value = '' 37 | this.setState({ 38 | chars_left: MAX_CHAR, 39 | }) 40 | }) 41 | } 42 | 43 | handleChange(event) { 44 | let input = event.target.value 45 | input = input.substring(0, MAX_CHAR) 46 | this.button.disabled = (input.length === 0) 47 | if (input.length === MAX_CHAR) { 48 | this.input.value = input 49 | } 50 | this.setState({ 51 | chars_left: MAX_CHAR - input.length, 52 | }) 53 | } 54 | 55 | render() { 56 | return ( 57 |
58 |
59 |
this.onSubmit(e)}> 60 | (this.input = node)} 64 | onChange={e => this.handleChange(e)} 65 | /> 66 |
{this.state.chars_left}/{MAX_CHAR}
67 | 68 |
69 |
70 |
71 | ) 72 | } 73 | } 74 | 75 | const withAddQuestion = graphql(CREATE_QUESTION_MUTATION, 76 | { 77 | props: ({ mutate }) => ({ 78 | addQuestion(body, id) { 79 | return mutate({ 80 | variables: { body: body, user: id }, 81 | updateQueries: { 82 | questions: (state, { mutationResult }) => { 83 | let newQuestion = mutationResult.data.createQuestion 84 | if (!isDuplicate(newQuestion)) { 85 | addToLocalCache(newQuestion) 86 | return update(state, { 87 | allQuestions: { 88 | $push: [newQuestion], 89 | }, 90 | }) 91 | } 92 | }, 93 | }, 94 | }).catch(error => { 95 | Alert.error(error.message, ALERT_DEFAULT) 96 | }) 97 | }, 98 | }), 99 | }, 100 | ) 101 | 102 | AddQuestion.propTypes = { 103 | auth: PropTypes.object.isRequired, 104 | } 105 | 106 | export default withAddQuestion(AddQuestion) 107 | -------------------------------------------------------------------------------- /src/components/HandsUpApp.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { graphql } from 'react-apollo' 4 | 5 | import { withRouter } from 'react-router-dom' 6 | 7 | import TopNavigation from './TopNavigation' 8 | import AddQuestion from './AddQuestion' 9 | import QuestionList from './QuestionList' 10 | 11 | import USER_QUERY from '../graphql/User.query.gql' 12 | import CREATE_USER_MUTATION from '../graphql/CreateUser.mutation.gql' 13 | 14 | import { setUserDetails, ALERT_DEFAULT } from '../utils/helpers' 15 | import Alert from 'react-s-alert' 16 | 17 | class HandsUpAppBase extends React.Component { 18 | 19 | constructor(props) { 20 | super(props) 21 | this.state = { 22 | isLogged: false, 23 | } 24 | this.props.auth.on('profile-updated', this.updateIsLogged.bind(this)) 25 | } 26 | 27 | updateIsLogged(profile) { 28 | this.setState({ 29 | isLogged: !!profile, 30 | }) 31 | } 32 | 33 | componentWillMount() { 34 | if (this.props.auth.profile) { 35 | this.updateState({ isLogged: true }) 36 | } 37 | } 38 | 39 | updateState(state) { 40 | this.setState(state) 41 | if (this.props.auth.profile) { 42 | const variables = setUserDetails(this.props.auth) 43 | this.props.createUser({ variables }).catch(e => { 44 | if (e.graphQLErrors) { 45 | e.graphQLErrors.forEach(error => { 46 | switch (error.code) { 47 | case 3023: 48 | break // existing user 49 | default: 50 | console.error(error) 51 | } 52 | }, this) 53 | } 54 | }) 55 | } 56 | } 57 | 58 | componentWillUnmount() { 59 | this.props.auth.removeListener('profile-updated', this.updateIsLogged.bind(this)) 60 | } 61 | 62 | render() { 63 | let addQuestion = null 64 | if (this.state.isLogged) { 65 | addQuestion = ( 66 | 67 | ) 68 | } 69 | 70 | return ( 71 |
72 | 73 | {addQuestion} 74 | 75 |
76 | 77 |
78 | ) 79 | } 80 | } 81 | const HandsUpApp = withRouter(HandsUpAppBase) 82 | 83 | const withUser = graphql(USER_QUERY, { 84 | options: { 85 | fetchPolicy: 'network-only', 86 | }, 87 | props: ({ ownProps, data }) => { 88 | // User logged using Auth0. This is graphcool userId 89 | // Eg: required to add questions and voting 90 | // We store it in the Authorisation class 91 | if (data.user && data.user.id) { 92 | ownProps.auth.userId = data.user.id 93 | } 94 | if (data.user && data.user.role) { 95 | ownProps.auth.role = data.user.role 96 | } 97 | if (data.user && data.user.flagged) { 98 | ownProps.auth.flagged = data.user.flagged 99 | } 100 | return { 101 | user: data.user, 102 | } 103 | }, 104 | }) 105 | 106 | const withCreateUser = graphql(CREATE_USER_MUTATION, { 107 | props: ({ ownProps, mutate }) => ({ 108 | createUser({ variables }) { 109 | // user already logged in 110 | if (ownProps.auth.profile && ownProps.auth.userId) { 111 | return Promise.resolve(null) 112 | } 113 | return mutate({ 114 | variables: { 115 | idToken: variables.idToken, 116 | name: variables.name, 117 | username: variables.username, 118 | pictureUrl: variables.profileUrl, 119 | role: variables.role, 120 | }, 121 | updateQueries: { 122 | questions: (state, { mutationResult }) => { 123 | ownProps.auth.userId = mutationResult.data.createUser.id 124 | return state 125 | }, 126 | }, 127 | }).catch(error => { 128 | if (error.graphQLErrors) { 129 | error.graphQLErrors.forEach(error => { 130 | switch (error.code) { 131 | case 3023: 132 | Alert.info('Welcome back! 👋', ALERT_DEFAULT) 133 | break // existing user 134 | default: 135 | Alert.error(error.message, ALERT_DEFAULT) 136 | } 137 | }, this) 138 | } 139 | }) 140 | }, 141 | }), 142 | }) 143 | 144 | HandsUpApp.propTypes = { 145 | auth: PropTypes.object.isRequired, 146 | } 147 | 148 | export default withCreateUser(withUser(HandsUpApp)) 149 | 150 | -------------------------------------------------------------------------------- /src/components/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default () => ( 4 |
5 |
Loading questions...
6 | 7 | 10 | 12 | 19 | 20 | 21 |
22 | ) 23 | -------------------------------------------------------------------------------- /src/components/ModeratorOptions.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { graphql } from 'react-apollo' 4 | import Alert from 'react-s-alert' 5 | import update from 'immutability-helper' 6 | 7 | import { ALERT_DEFAULT } from '../utils/helpers' 8 | import FLAG_QUESTION_MUTATION from '../graphql/FlagQuestion.mutation.gql' 9 | import FLAG_USER_MUTATION from '../graphql/FlagUser.mutation.gql' 10 | 11 | class ModeratorOptions extends React.Component { 12 | flagUser(question) { 13 | if (question.flagged) { 14 | this.props.flagUser(question.user.id, question.user.flagged) 15 | } else { 16 | this.props.flagQuestion(question.id, question.flagged).then(() => { 17 | this.props.flagUser(question.user.id, question.user.flagged) 18 | }) 19 | } 20 | } 21 | 22 | flagQuestion(question) { 23 | this.props.flagQuestion(question.id, question.flagged) 24 | } 25 | 26 | render() { 27 | if (this.props.role && this.props.role !== 'User') { 28 | return ( 29 |
30 | 37 | 43 |
44 | ) 45 | } else { 46 | return null 47 | } 48 | } 49 | } 50 | 51 | const withFlagQuestion = graphql(FLAG_QUESTION_MUTATION, { 52 | props: ({ mutate }) => ({ 53 | flagQuestion(id, flagged) { 54 | return mutate({ 55 | variables: { question: id, flagged: !flagged }, 56 | updateQueries: { 57 | questions: (state, { mutationResult }) => { 58 | let newQuestion = mutationResult.data.updateQuestion 59 | let newArray = state.allQuestions.map(q => { 60 | if (q.id === id) { 61 | return newQuestion 62 | } 63 | return q 64 | }) 65 | return update(state, { 66 | questions: { $set: newArray }, 67 | }) 68 | }, 69 | }, 70 | }).catch(error => { 71 | Alert.error(error.message, ALERT_DEFAULT) 72 | }) 73 | }, 74 | }), 75 | }) 76 | 77 | const withFlagUser = graphql(FLAG_USER_MUTATION, { 78 | props: ({ mutate }) => ({ 79 | flagUser(id, flagged) { 80 | return mutate({ 81 | variables: { user: id, flagged: !flagged }, 82 | updateQueries: { 83 | questions: (state, { mutationResult }) => { 84 | return state 85 | }, 86 | }, 87 | }).catch(error => { 88 | Alert.error(error.message, ALERT_DEFAULT) 89 | }) 90 | }, 91 | }), 92 | }) 93 | 94 | ModeratorOptions.propTypes = { 95 | question: PropTypes.object.isRequired, 96 | } 97 | 98 | export default withFlagUser(withFlagQuestion(ModeratorOptions)) 99 | -------------------------------------------------------------------------------- /src/components/Profile.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | class Profile extends React.Component { 5 | 6 | render() { 7 | if (!this.props.isLogged) { 8 | return null 9 | } 10 | 11 | return ( 12 |
13 | 14 |
15 | ) 16 | } 17 | } 18 | 19 | Profile.propTypes = { 20 | isLogged: PropTypes.bool.isRequired, 21 | } 22 | 23 | export default Profile 24 | -------------------------------------------------------------------------------- /src/components/Question.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { graphql } from 'react-apollo' 4 | import Votes from './Votes' 5 | import { flyingHearts, DEFAULT_PROFILE_PIC, ALERT_DEFAULT } from '../utils/helpers' 6 | import TimeAgo from 'react-timeago' 7 | import TweetParser from './TweetParser' 8 | import Alert from 'react-s-alert' 9 | 10 | import ModeratorOptions from './ModeratorOptions' 11 | 12 | import CREATE_VOTE_MUTATION from '../graphql/Vote.mutation.gql' 13 | 14 | class Question extends React.Component { 15 | 16 | constructor(props) { 17 | super(props) 18 | this.state = { 19 | votes: this.props.question._votesMeta.count, 20 | isTheAuthor: this.isTheAuthor(this.props.auth.userId), 21 | } 22 | this.props.auth.on('user-id-updated', this.updateState.bind(this)) 23 | } 24 | 25 | updateState(userId) { 26 | this.setState({ 27 | isTheAuthor: this.isTheAuthor(userId), 28 | }) 29 | } 30 | 31 | isTheAuthor(userId) { 32 | let questionUserId 33 | 34 | if (this.props.question && this.props.question.user) { 35 | questionUserId = this.props.question.user.id 36 | } 37 | return (userId === questionUserId) 38 | } 39 | 40 | componentDidMount() { 41 | // not working for now 42 | // window.scroll({ top: this.elem.scrollHeight, left: 0, behavior: 'smooth' }) 43 | } 44 | 45 | componentWillReceiveProps(nextProps) { 46 | // re-render only when votes have changed 47 | if (nextProps.question && this.props.question) { 48 | let newVotes = nextProps.question._votesMeta.count 49 | let currentVotes = this.props.question._votesMeta.count 50 | 51 | if (newVotes !== currentVotes) { 52 | this.setState({ 53 | votes: newVotes, 54 | }) 55 | } 56 | } 57 | } 58 | 59 | componentWillUnmount() { 60 | this.props.auth.removeListener('user-id-updated', this.updateState.bind(this)) 61 | } 62 | 63 | formatter(value, unit, suffix, date, defaultFormatter) { 64 | if (unit === 'second' && value < 60) { 65 | return 'just now' 66 | } 67 | return defaultFormatter(value, unit, suffix, date) 68 | } 69 | 70 | onSubmit() { 71 | this.setState({ 72 | votes: this.state.votes+1, 73 | isTheAuthor: this.isTheAuthor(this.props.auth.userId), 74 | }) 75 | this.props 76 | .vote(this.props.question.id) 77 | .catch(e => { 78 | if (e.graphQLErrors) { 79 | e.graphQLErrors.forEach(error => { 80 | switch (error.code) { 81 | case 3023: 82 | break 83 | default: 84 | console.error(error) 85 | } 86 | }, this) 87 | } 88 | }) 89 | flyingHearts('.flying-hearts') 90 | } 91 | 92 | flagUser(question) { 93 | this.props.flag(question.id, question.flagged).then(() => { 94 | this.props.flagUser(question.user.id, question.user.flagged) 95 | }) 96 | } 97 | flagQuestion(question) { 98 | this.props.flag(question.id, question.flagged) 99 | } 100 | moderatorOptions() { 101 | // hide flagged questions from organisers 102 | if (this.props.auth.role==='User' || this.props.auth.role==='Organiser' || !this.props.auth.role) { 103 | return null 104 | } else { 105 | return ( 106 | 109 | ) 110 | } 111 | } 112 | 113 | render() { 114 | // hide flagged questions from users 115 | if (this.props.question.flagged && (this.props.auth.role==='User' || this.props.auth.role==='Organiser' || !this.props.auth.role)) { 116 | return null 117 | } 118 | return ( 119 |
  • 120 |
    (this.elem = elem)}> 121 |
    122 |
    123 | {this.props.question.body}
    124 | 125 |
    126 |
    127 | 128 |
    129 |
    by {this.props.question.user? this.props.question.user.username : '@happylama'} 130 | , 131 | 132 |
    133 |
    134 |
    135 |
    136 |
    137 | 141 |
    142 |
    143 |
    144 | {this.moderatorOptions()} 145 |
  • 146 | ) 147 | } 148 | } 149 | 150 | const withVote = graphql(CREATE_VOTE_MUTATION, 151 | { 152 | props: ({ mutate }) => ({ 153 | vote(id) { 154 | return mutate({ 155 | variables: { question: id }, 156 | }).catch(error => { 157 | Alert.error(error.message, ALERT_DEFAULT) 158 | }) 159 | }, 160 | }), 161 | }, 162 | ) 163 | 164 | Question.propTypes = { 165 | question: PropTypes.object.isRequired, 166 | auth: PropTypes.object.isRequired, 167 | } 168 | 169 | export default withVote(Question) 170 | -------------------------------------------------------------------------------- /src/components/QuestionList.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Question from './Question' 4 | import { graphql } from 'react-apollo' 5 | 6 | import update from 'immutability-helper' 7 | import { isDuplicate, POLLING_TIME } from '../utils/helpers' 8 | import Loading from './Loading' 9 | 10 | import QUESTIONS_QUERY from '../graphql/Questions.query.gql' 11 | import QUESTIONS_SUBSCRIPTION from '../graphql/Questions.subscription.gql' 12 | 13 | class QuestionList extends React.Component { 14 | 15 | componentWillMount() { 16 | this.props.subscribeToNewQuestions() 17 | } 18 | 19 | refetch() { 20 | this.props.data.refetch() 21 | } 22 | 23 | isEmpty() { 24 | return (!this.props.loading && (!this.props.questions || 25 | this.props.questions && (this.props.questions.length===0) || (this.props.questions.filter(q => q.flagged).length===this.props.questions.length && !this.props.auth.role))) 26 | } 27 | 28 | refresh() { 29 | if (!this.isEmpty() && !this.props.loading) { 30 | return ( 31 |
      32 |
    • 33 |
      34 | 35 |
      36 |
    • 37 |
    38 | ) 39 | } else { 40 | return null 41 | } 42 | } 43 | 44 | render() { 45 | return ( 46 |
    47 |
      48 | { this.props.questions && this.props.questions.map((q, i) => 49 | 54 | ).sort((a, b) => { 55 | if ((this.props.auth.role==='User' || !this.props.auth.role)) return 0 56 | 57 | return (b.props.question._votesMeta.count - a.props.question._votesMeta.count) 58 | }) 59 | } 60 |
    61 | {this.refresh()} 62 | {this.isEmpty()?
    There are no questions.
    : null } 63 | {this.props.loading ? : null} 64 |
    65 |
    66 | ) 67 | } 68 | } 69 | 70 | const withQuestions = graphql(QUESTIONS_QUERY, 71 | { 72 | options: { pollInterval: POLLING_TIME }, 73 | props: ({ data }) => { 74 | if (data.loading) return { loading: true } 75 | if (data.error) return { hasErrors: true } 76 | return { 77 | questions: data.allQuestions, 78 | data, 79 | } 80 | }, 81 | }, 82 | ) 83 | 84 | const withSubscription = graphql(QUESTIONS_QUERY, 85 | { 86 | props: ({ data: { subscribeToMore } }) => ({ 87 | subscribeToNewQuestions() { 88 | return subscribeToMore({ 89 | document: QUESTIONS_SUBSCRIPTION, 90 | updateQuery: (state, { subscriptionData }) => { 91 | const newQuestion = subscriptionData.data.Question.node 92 | if (!isDuplicate(newQuestion.id, state.allQuestions)) { 93 | return update(state, { 94 | allQuestions: { 95 | $push: [newQuestion], 96 | }, 97 | }) 98 | } 99 | }, 100 | }) 101 | }, 102 | }), 103 | }, 104 | ) 105 | 106 | QuestionList.propTypes = { 107 | auth: PropTypes.object.isRequired, 108 | } 109 | 110 | export default withSubscription(withQuestions(QuestionList)) 111 | -------------------------------------------------------------------------------- /src/components/TopNavigation.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Profile from './Profile' 4 | 5 | class TopNavigation extends React.Component { 6 | 7 | constructor(props) { 8 | super(props) 9 | this.handleLoginClick = this.handleLoginClick.bind(this) 10 | this.handleLogoutClick = this.handleLogoutClick.bind(this) 11 | } 12 | 13 | handleLoginClick() { 14 | this.props.auth.authenticate() 15 | } 16 | 17 | handleLogoutClick() { 18 | this.props.auth.logout(this.props.client) 19 | } 20 | 21 | render() { 22 | return ( 23 | 53 | ) 54 | } 55 | } 56 | 57 | TopNavigation.propTypes = { 58 | auth: PropTypes.object.isRequired, 59 | isLogged: PropTypes.bool.isRequired, 60 | } 61 | 62 | export default TopNavigation 63 | -------------------------------------------------------------------------------- /src/components/TweetParser.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | class TweetParser extends React.Component { 5 | 6 | generateLink(url, urlClass, target, text) { 7 | return `${text}` 8 | } 9 | 10 | render() { 11 | const { 12 | urlClass, 13 | userClass, 14 | hashtagClass, 15 | target, 16 | searchWithHashtags, 17 | parseUsers, 18 | parseUrls, 19 | parseHashtags, 20 | } = this.props 21 | 22 | const REGEX_URL = /(?:\s)(f|ht)tps?:\/\/([^\s\t\r\n<]*[^\s\t\r\n<)*_,\.])/g // regex for urls 23 | const REGEX_USER = /\B@([a-zA-Z0-9_]+)/g // regex for @users 24 | const REGEX_HASHTAG = /\B(#[á-úÁ-Úä-üÄ-Üa-zA-Z0-9_]+)/g // regex for #hashtags 25 | 26 | let tweet = this.props.children 27 | let searchlink // search link for hashtags 28 | // Hashtag Search link 29 | if (searchWithHashtags) { 30 | // this is the search with hashtag 31 | searchlink = 'https://twitter.com/hashtag/' 32 | } else { 33 | // this is a more global search including hashtags and the word itself 34 | searchlink = 'https://twitter.com/search?q=' 35 | } 36 | // turn URLS in the tweet into... working urls 37 | if (parseUrls) { 38 | tweet = tweet.replace(REGEX_URL, url => { 39 | let link = this.generateLink(url, urlClass, target, url) 40 | return url.replace(url, link) 41 | }) 42 | } 43 | // turn @users in the tweet into... working urls 44 | if (parseUsers) { 45 | tweet = tweet.replace(REGEX_USER, user => { 46 | let userOnly = user.slice(1) 47 | let url = `http://twitter.com/${userOnly}` 48 | let link = this.generateLink(url, userClass, target, user) 49 | return user.replace(user, link) 50 | }) 51 | } 52 | // turn #hashtags in the tweet into... working urls 53 | if (parseHashtags) { 54 | tweet = tweet.replace(REGEX_HASHTAG, hashtag => { 55 | let hashtagOnly = hashtag.slice(1) 56 | let url = searchlink + hashtagOnly 57 | let link = this.generateLink(url, hashtagClass, target, hashtag) 58 | return hashtag.replace(hashtag, link) 59 | }) 60 | } 61 | 62 | return

    63 | } 64 | } 65 | 66 | TweetParser.propTypes = { 67 | urlClass: PropTypes.string, 68 | userClass: PropTypes.string, 69 | hashtagClass: PropTypes.string, 70 | target: PropTypes.string, 71 | searchWithHashtags: PropTypes.bool, 72 | parseUsers: PropTypes.bool, 73 | parseUrls: PropTypes.bool, 74 | parseHashtags: PropTypes.bool, 75 | } 76 | 77 | TweetParser.defaultProps = { 78 | urlClass: 'react-tweet-parser__url', 79 | userClass: 'react-tweet-parser__user', 80 | hashtagClass: 'react-tweet-parser__hashTag', 81 | target: '_blank', 82 | searchWithHashtags: true, 83 | parseUsers: true, 84 | parseUrls: true, 85 | parseHashtags: true, 86 | } 87 | 88 | export default TweetParser 89 | -------------------------------------------------------------------------------- /src/components/Votes.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { getBaseLog } from '../utils/helpers' 4 | 5 | class Votes extends React.Component { 6 | 7 | render() { 8 | if (this.props.votes!==0 && !this.props.votes) { 9 | return null 10 | } 11 | let blocks = getBaseLog(this.props.votes) 12 | return ( 13 |

      14 | {Array.apply(null, Array(blocks)) 15 | .map((x, i) => { 16 | return ( 17 |
    • 18 | ) 19 | }, this)} 20 |
    21 | ) 22 | } 23 | } 24 | 25 | Votes.propTypes = { 26 | votes: PropTypes.number.isRequired, 27 | } 28 | 29 | export default Votes 30 | -------------------------------------------------------------------------------- /src/graphql/CreateQuestion.mutation.gql: -------------------------------------------------------------------------------- 1 | #import "./Question.fragment.gql" 2 | 3 | mutation addQuestion($body: String!, $user: ID!) { 4 | createQuestion(body: $body, userId: $user) { 5 | ...question 6 | } 7 | } -------------------------------------------------------------------------------- /src/graphql/CreateUser.mutation.gql: -------------------------------------------------------------------------------- 1 | mutation createUser( 2 | $idToken: String!, 3 | $name: String!, 4 | $username: String!, 5 | $pictureUrl: String!, 6 | $role: USER_ROLE 7 | ){ 8 | createUser( 9 | authProvider: { 10 | auth0: { 11 | idToken: $idToken 12 | } 13 | }, 14 | name: $name, 15 | username: $username, 16 | pictureUrl: $pictureUrl 17 | role: $role 18 | ) { 19 | id role 20 | } 21 | } -------------------------------------------------------------------------------- /src/graphql/FlagQuestion.mutation.gql: -------------------------------------------------------------------------------- 1 | #import "./Question.fragment.gql" 2 | 3 | mutation updateQuestion($question: ID!, $flagged: Boolean) { 4 | updateQuestion(id: $question, flagged: $flagged) { 5 | ...question 6 | } 7 | } -------------------------------------------------------------------------------- /src/graphql/FlagUser.mutation.gql: -------------------------------------------------------------------------------- 1 | mutation updateUser($user: ID!, $flagged: Boolean) { 2 | updateUser(id: $user, flagged: $flagged) { 3 | id 4 | } 5 | } -------------------------------------------------------------------------------- /src/graphql/Question.fragment.gql: -------------------------------------------------------------------------------- 1 | fragment question on Question { 2 | id 3 | body 4 | createdAt 5 | _votesMeta { count } 6 | user { id username pictureUrl flagged } 7 | flagged 8 | } -------------------------------------------------------------------------------- /src/graphql/Questions.query.gql: -------------------------------------------------------------------------------- 1 | #import "./Question.fragment.gql" 2 | 3 | query questions { 4 | allQuestions { 5 | ...question 6 | } 7 | } -------------------------------------------------------------------------------- /src/graphql/Questions.subscription.gql: -------------------------------------------------------------------------------- 1 | #import "./Question.fragment.gql" 2 | 3 | subscription { 4 | Question(filter: { 5 | mutation_in: [CREATED, UPDATED] 6 | }) { 7 | node { 8 | ...question 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /src/graphql/User.query.gql: -------------------------------------------------------------------------------- 1 | query user { 2 | user { 3 | id role flagged 4 | } 5 | } -------------------------------------------------------------------------------- /src/graphql/Vote.mutation.gql: -------------------------------------------------------------------------------- 1 | mutation createVote($question: ID!) { 2 | createVote(questionId: $question) { 3 | id 4 | } 5 | } -------------------------------------------------------------------------------- /src/graphql/Vote.query.gql: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gsans/handsup-react/75c6d7904309c2e75220cce9284f9e0b5373a489/src/graphql/Vote.query.gql -------------------------------------------------------------------------------- /src/images/handsup.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gsans/handsup-react/75c6d7904309c2e75220cce9284f9e0b5373a489/src/images/handsup.gif -------------------------------------------------------------------------------- /src/images/partyparrot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gsans/handsup-react/75c6d7904309c2e75220cce9284f9e0b5373a489/src/images/partyparrot.png -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | HandsUp 🙌 - Make your events more interactive! 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
    25 | 26 | 27 | -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Route, IndexRoute } from 'react-router' 3 | 4 | import TodoApp from './components/TodoApp' 5 | 6 | export default ( 7 | 11 | 14 | 15 | ); -------------------------------------------------------------------------------- /src/services/Authorisation.js: -------------------------------------------------------------------------------- 1 | import Auth0Lock from 'auth0-lock' 2 | import { EventEmitter } from 'events' 3 | 4 | const CLIENT_ID = 'Rwy4qqy5uEbGyLEGJBI1VOeDVSqDUTz0' 5 | const DOMAIN = 'public.eu.auth0.com' 6 | 7 | export default class Authorisation extends EventEmitter { 8 | 9 | constructor() { 10 | super() 11 | this.lock = new Auth0Lock(CLIENT_ID, DOMAIN, { 12 | theme: { 13 | logo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/0/02/Emoji_u1f64c.svg/2000px-Emoji_u1f64c.svg.png', 14 | primaryColor: '#31324F', 15 | }, 16 | auth: { 17 | responseType: 'id_token', 18 | params: { scope: 'openid email' }, 19 | redirect: false, 20 | }, 21 | }) 22 | this.lock.on('authenticated', this.doAuthentication.bind(this)) 23 | if (this.setMaxListeners) { 24 | this.setMaxListeners(10000) 25 | } 26 | } 27 | 28 | get auth0IdToken() { 29 | return localStorage.getItem('auth0IdToken') 30 | } 31 | set auth0IdToken(value) { 32 | if (value) { 33 | localStorage.setItem('auth0IdToken', value) 34 | } else { 35 | localStorage.removeItem('auth0IdToken') 36 | } 37 | } 38 | 39 | get profile() { 40 | return JSON.parse(localStorage.getItem('profile')) 41 | } 42 | set profile(value) { 43 | if (value) { 44 | localStorage.setItem('profile', JSON.stringify(value)) 45 | } else { 46 | localStorage.removeItem('profile') 47 | } 48 | this.emit('profile-updated', value) 49 | } 50 | 51 | get userId() { 52 | return JSON.parse(localStorage.getItem('userId')) 53 | } 54 | set userId(value) { 55 | if (value) { 56 | localStorage.setItem('userId', JSON.stringify(value)) 57 | } else { 58 | localStorage.removeItem('userId') 59 | } 60 | this.emit('user-id-updated', value) 61 | } 62 | 63 | get role() { 64 | return JSON.parse(localStorage.getItem('role')) 65 | } 66 | set role(value) { 67 | if (value) { 68 | localStorage.setItem('role', JSON.stringify(value)) 69 | } else { 70 | localStorage.removeItem('role') 71 | } 72 | } 73 | 74 | get flagged() { 75 | return JSON.parse(localStorage.getItem('flagged')) 76 | } 77 | set flagged(value) { 78 | if (value) { 79 | localStorage.setItem('flagged', JSON.stringify(value)) 80 | // we logout the user if flagged 81 | this.logout() 82 | } else { 83 | localStorage.removeItem('flagged') 84 | } 85 | } 86 | 87 | authenticate() { 88 | this.lock.show() 89 | } 90 | 91 | doAuthentication(authResult) { 92 | // flagged users can't login 93 | if (!this.profile) { 94 | this.auth0IdToken = authResult.idToken 95 | this.lock.getProfile(authResult.idToken, (error, profile) => { 96 | if (error) { 97 | console.error('Error loading the User Profile', error) 98 | this.auth0IdToken = null 99 | this.profile = null 100 | } else { 101 | this.profile = profile 102 | location.href = '/' 103 | } 104 | }) 105 | } 106 | } 107 | 108 | logout(client) { 109 | if (client) { 110 | // clear apollo client cache 111 | client.resetStore() 112 | } 113 | this.auth0IdToken = null 114 | this.profile = null 115 | this.userId = null 116 | this.role = null 117 | this.flagged = null 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | @import "https://fonts.googleapis.com/css?family=Roboto:400,100,100italic,300,300italic,400italic,500,500italic,700,700italic,900,900italic&subset=latin,cyrillic"; 2 | 3 | :root { 4 | --filterx2: contrast(1) brightness(1.2) hue-rotate(-20deg); 5 | --filterx3: contrast(0.2) brightness(1.4); 6 | } 7 | 8 | * { 9 | -webkit-font-smoothing: antialiased !important; 10 | -moz-osx-font-smoothing: grayscale; 11 | text-shadow: 0px 0px 3px rgba(0, 0, 0, 0.2); 12 | } 13 | body { 14 | font-family: "Roboto", 'Helvetica Neue, Helvetica, Arial', sans-serif; 15 | overflow-y: hidden; 16 | height: 100%; 17 | scroll-behavior: smooth; 18 | } 19 | #root, html { 20 | height: 100%; 21 | } 22 | 23 | .app { 24 | margin: 0px; 25 | height: 100%; 26 | overflow-y: auto; 27 | } 28 | .app::before { 29 | content: ''; 30 | margin-bottom: 50px; 31 | display: block; 32 | } 33 | ul { 34 | margin-bottom: 85px; 35 | } 36 | .list { 37 | margin-bottom: 85px; 38 | overflow: hidden; 39 | } 40 | 41 | .navbar { 42 | height: 60px; 43 | } 44 | .navbar-inverse { 45 | background: -webkit-linear-gradient(to top right, #e91e63, #642889); 46 | background: -moz-linear-gradient(to top right, #e91e63, #642889); 47 | background: -o-linear-gradient(to top right, #e91e63, #642889); 48 | background: linear-gradient(to top right, #e91e63, #642889); 49 | border-bottom: 2px solid #e91e63; 50 | } 51 | 52 | li.complete { 53 | text-decoration: line-through; 54 | } 55 | 56 | a.inactive { 57 | text-decoration: none; 58 | } 59 | 60 | .profile img { 61 | width: 70px; 62 | border-radius: 50%; 63 | border: 5px solid #e91e63; 64 | margin-top: 0px; 65 | -webkit-box-shadow: 0 0px 10px rgba(233, 30, 99, 0.5); 66 | -moz-box-shadow: 0 0px 10px rgba(233, 30, 99, 0.5); 67 | box-shadow: 0 0px 10px rgba(233, 30, 99, 0.5); 68 | background: white; 69 | } 70 | .profile { 71 | position: absolute; 72 | margin-top: 10px; 73 | } 74 | .profile-container { 75 | display: block; 76 | } 77 | .profile-small { 78 | position: relative; 79 | float: left; 80 | margin-right: 10px; 81 | } 82 | .profile-small img { 83 | width: 3.5em; 84 | border-radius: 50%; 85 | border: 2px solid #eee; 86 | margin-top: 0px; 87 | background: white; 88 | filter: var(--filterx2); 89 | -webkit-filter: var(--filterx2); 90 | -moz-filter: var(--filterx2); 91 | -ms-filter: var(--filterx2); 92 | -o-filter: var(--filterx2); 93 | } 94 | .profile-small-text { 95 | line-height: 3; 96 | color: #aaa; 97 | text-shadow: none; 98 | font-style: italic; 99 | font-size: 1.5em; 100 | text-overflow: ellipsis; 101 | overflow: hidden; 102 | white-space: nowrap; 103 | } 104 | .profile-small-text time { 105 | text-shadow: none; 106 | } 107 | 108 | .button-top { 109 | margin-top: 9px; 110 | } 111 | 112 | ul { 113 | list-style: none; 114 | padding-left: 0px; 115 | } 116 | li { 117 | padding: 55px 20% 55px 20%; 118 | } 119 | 120 | ul.blocks { 121 | display: flex; 122 | margin-left: 2px; 123 | } 124 | .blocks li { 125 | padding: 0px; 126 | float:left; 127 | font-size: 20px; 128 | } 129 | .block { 130 | border-radius: 1px; 131 | border: 1px solid #e91e63; 132 | background: #e91e63; 133 | width: 10%; 134 | height: 5px; 135 | margin: 15px 10px 15px 0px; 136 | -webkit-box-shadow: 0 0px 10px rgba(233, 30, 99, 0.5); 137 | -moz-box-shadow: 0 0px 10px rgba(233, 30, 99, 0.5); 138 | box-shadow: 0 0px 10px rgba(233, 30, 99, 0.5); 139 | } 140 | 141 | li { 142 | background: #f9f9f9; /* fallback */ 143 | background: -webkit-linear-gradient(0deg,#f9f9f9, #fff); /* For Safari 5.1 to 6.0 */ 144 | background: -o-linear-gradient(0deg,#f9f9f9, #fff); /* For Opera 11.1 to 12.0 */ 145 | background: -moz-linear-gradient(0deg,#f9f9f9, #fff); /* For Firefox 3.6 to 15 */ 146 | background: linear-gradient(0deg, #f9f9f9, #fff); /* Standard syntax */ 147 | } 148 | 149 | .text-header { 150 | font-style: italic; 151 | font-size: 1em; 152 | color: #888; 153 | text-align: left; 154 | } 155 | .text-body { 156 | font-weight: normal; 157 | font-size: 3em; 158 | color: #424242; 159 | line-height: 1; 160 | padding: 2px; 161 | } 162 | .text-body p { 163 | padding: 3px; 164 | } 165 | 166 | .votes { 167 | font-style: italic; 168 | } 169 | 170 | /* fixes for bootstrap */ 171 | .nav>li>a:focus, .nav>li>a:hover { 172 | text-decoration: none; 173 | background-color: rgba(0,0,0,0); 174 | } 175 | .navbar-collapse { 176 | border: 0px; 177 | -webkit-box-shadow: none; 178 | box-shadow: none; 179 | } 180 | 181 | .btn-primary { 182 | transition: all 0.2s ease-in-out; 183 | border-radius: 40px; 184 | height: 40px; 185 | } 186 | .btn-primary:not([disabled]), .btn-primary:hover:not([disabled]), .btn-primary:focus:not([disabled]), .btn-primary:active:not([disabled]), .btn-primary:active:hover:not([disabled]) { 187 | background-color: #e91e63; 188 | border-color: #e91e63; 189 | } 190 | .btn-primary:hover:not([disabled]),.btn-primary:active, .btn-primary:active:hover:not([disabled]) { 191 | background-color: #ff0a5c; 192 | border-color: #ff0a5c; 193 | } 194 | 195 | .centerBlock { 196 | display: table; 197 | margin: 0 auto; 198 | } 199 | .app-title { 200 | font-size: 35px; 201 | color: #00cafe; 202 | line-height: 1.9; 203 | } 204 | 205 | .vote { 206 | position: relative; 207 | margin-top: 30px; 208 | } 209 | .btn-circle { 210 | width: 60px; 211 | height: 60px; 212 | text-align: center; 213 | padding: 6px 0; 214 | font-size: 12px; 215 | line-height: 1.428571429; 216 | border-radius: 50%; 217 | background-color: #00cafe; 218 | color: white; 219 | -webkit-box-shadow: 0 0px 10px rgba(0, 202, 244, 0.5); 220 | -moz-box-shadow: 0 0px 10px rgba(0, 202, 244, 0.5); 221 | box-shadow: 0 0px 10px rgba(0, 202, 244, 0.5); 222 | transition: all 0.2s ease-in-out; 223 | } 224 | .btn-circle:hover, .btn-circle:focus, .btn-circle:active, .btn-circle:active:hover { 225 | color: white; 226 | background-color: #33d6ff; 227 | } 228 | .btn-circle.disabled, .btn-circle[disabled], .btn-circle:disabled, .btn-circle:active:hover[disabled], .btn-circle:hover[disabled] { 229 | background-color: #aaa; 230 | } 231 | 232 | .btn-circle.btn-lg { 233 | width: 50px; 234 | height: 50px; 235 | padding: 10px 16px; 236 | font-size: 18px; 237 | line-height: 1.33; 238 | border-radius: 25px; 239 | } 240 | .btn-circle.btn-xl { 241 | width: 70px; 242 | height: 70px; 243 | padding: 10px 16px; 244 | font-size: 24px; 245 | line-height: 1.33; 246 | border-radius: 35px; 247 | } 248 | 249 | .bottom_wrapper { 250 | border-radius: 10px 10px 0px 0px; 251 | width: 50%; 252 | background: -webkit-linear-gradient(to top right, #e91e63, #642889); 253 | background: -moz-linear-gradient(to top right, #e91e63, #642889); 254 | background: -o-linear-gradient(to top right, #e91e63, #642889); 255 | background: linear-gradient(to top right, #e91e63, #642889); 256 | padding: 20px 20px; 257 | position: fixed; 258 | bottom: 0px; 259 | left: 25%; 260 | z-index: 1; 261 | } 262 | .bottom_wrapper .message_input_wrapper { 263 | display: inline-block; 264 | height: 50px; 265 | border-radius: 10px; 266 | width: calc(100% - 75px); 267 | position: relative; 268 | padding: 0 20px; 269 | background: white; 270 | } 271 | .bottom_wrapper .message_input_wrapper .message_input { 272 | border: none; 273 | height: 100%; 274 | box-sizing: border-box; 275 | width: calc(100% - 40px); 276 | position: absolute; 277 | outline-width: 0; 278 | color: #424242; 279 | left: 15px; 280 | } 281 | .bottom_wrapper .send_message { 282 | width: 60px; 283 | height: 50px; 284 | border-radius: 10px; 285 | background-color: #00cafe; 286 | border: 2px solid #00cafe; 287 | color: #fff; 288 | cursor: pointer; 289 | transition: all 0.2s ease-in-out; 290 | text-align: center; 291 | position: absolute; 292 | right: -75px; 293 | } 294 | button.send_message:disabled, 295 | button.send_message[disabled]{ 296 | opacity: 0.65; 297 | cursor: not-allowed; 298 | border: 2px solid #848484; 299 | } 300 | button:not([disabled]) button.send_message:hover, button.send_message:active:hover { 301 | background-color: #33d6ff; 302 | } 303 | 304 | .bottom_wrapper .send_message .text { 305 | display: inline-block; 306 | line-height: 48px; 307 | } 308 | 309 | .counter { 310 | color: #bbb; 311 | position: relative; 312 | float: right; 313 | top: 35px; 314 | right: -14px; 315 | text-align: right; 316 | font-style: italic; 317 | font-size: 10px; 318 | text-shadow: none; 319 | } 320 | 321 | 322 | @media only screen and (max-width: 767px) { 323 | .profile img { 324 | width: 60px; 325 | } 326 | .app-title { 327 | font-size: 25px; 328 | line-height: 2.3; 329 | } 330 | .vote { 331 | margin-top: -10px; 332 | xfloat: right!important; 333 | right: 32px; 334 | } 335 | .counter { 336 | top: 35px; 337 | } 338 | .bottom_wrapper { 339 | width: 100%; 340 | left: 0px; 341 | border-radius: 0px; 342 | } 343 | } 344 | 345 | @media only screen and (max-width: 992px) { 346 | li { 347 | padding: 55px 5% 55px 5%; 348 | } 349 | } 350 | 351 | @media only screen and (max-width: 320px) { 352 | .app-title { 353 | font-size: 22px; 354 | line-height: 2; 355 | } 356 | } 357 | 358 | 359 | div.flying-hearts { 360 | width: 200px; 361 | height: 100%; 362 | position: absolute; 363 | bottom: 0; 364 | left: 50%; 365 | margin-left: -50px; 366 | pointer-events: none; 367 | } 368 | div.heart { 369 | width: 30px; 370 | height: 30px; 371 | opacity: 1; 372 | position: absolute; 373 | bottom: 0; 374 | display: none 375 | } 376 | div.heart i { 377 | position: absolute; 378 | left: 0; 379 | top: 0; 380 | opacity: .3 381 | } 382 | div.heart i.fa-heart-o { 383 | z-index: 1; 384 | opacity: .8 385 | } 386 | @keyframes flying1 { 387 | 0% { 388 | opacity: 0; 389 | bottom: 0; 390 | left: 34% 391 | } 392 | 40% { 393 | opacity: .8 394 | } 395 | 50% { 396 | opacity: 1; 397 | left: 0 398 | } 399 | 60% { 400 | opacity: .2 401 | } 402 | 100% { 403 | opacity: 0; 404 | bottom: 100%; 405 | left: 38% 406 | } 407 | } 408 | @keyframes flying2 { 409 | 0% { 410 | opacity: 0; 411 | bottom: 0; 412 | left: 0 413 | } 414 | 40% { 415 | opacity: .8 416 | } 417 | 50% { 418 | opacity: 1; 419 | left: 25% 420 | } 421 | 60% { 422 | opacity: .2 423 | } 424 | 100% { 425 | opacity: 0; 426 | bottom: 80%; 427 | left: 0 428 | } 429 | } 430 | @keyframes flying3 { 431 | 0% { 432 | opacity: 0; 433 | bottom: 0; 434 | left: 0 435 | } 436 | 40% { 437 | opacity: .8 438 | } 439 | 50% { 440 | opacity: 1; 441 | left: 50% 442 | } 443 | 60% { 444 | opacity: .2 445 | } 446 | 100% { 447 | opacity: 0; 448 | bottom: 90%; 449 | left: 0 450 | } 451 | } 452 | 453 | a.twitter { 454 | color: #e91e63; 455 | text-decoration: none; 456 | } 457 | 458 | a.twitter:hover { 459 | text-decoration: underline; 460 | } 461 | 462 | /* alerts styling */ 463 | .s-alert-box { 464 | border: 1px solid transparent; 465 | opacity: .9; 466 | } 467 | .s-alert-wrapper .s-alert-info { 468 | background: #e91e63; 469 | color: #fff; 470 | } 471 | .s-alert-wrapper .s-alert-success { 472 | background: #45edc6; 473 | color: #fff; 474 | } 475 | .s-alert-wrapper .s-alert-warning { 476 | background: #ffcc33; 477 | color: #fff; 478 | } 479 | .s-alert-wrapper .s-alert-error { 480 | background: #f44336; 481 | color: #fff; 482 | } 483 | 484 | .centered { 485 | position: fixed; 486 | text-align: center; 487 | left: 50%; 488 | top: 50%; 489 | background-color: white; 490 | height: 150px; 491 | width: 350px; 492 | margin-top: -90px; 493 | margin-left: -175px; 494 | } 495 | svg#loader-1 { 496 | height: 60px; 497 | margin-top: 20px; 498 | } 499 | 500 | .list .btn { 501 | margin-right: 10px; 502 | margin-top: 20px; 503 | } 504 | 505 | li.flagged, li.flagged row { 506 | background: #eeeeee; 507 | } 508 | 509 | li.flagged ul li { 510 | background-color: #999; 511 | border-color: #999; 512 | box-shadow: 0 0px 10px #999; 513 | color: #999; 514 | } 515 | li.flagged p { 516 | color: #999; 517 | } 518 | 519 | li.flagged .profile-small img { 520 | filter: var(--filterx3); 521 | -webkit-filter: var(--filterx3); 522 | -moz-filter: var(--filterx3); 523 | -ms-filter: var(--filterx3); 524 | -o-filter: var(--filterx3); 525 | } -------------------------------------------------------------------------------- /src/utils/helpers.js: -------------------------------------------------------------------------------- 1 | var $ = window.$ 2 | export const DEFAULT_PROFILE_USERNAME = 'happy-lama' 3 | export const DEFAULT_PROFILE_PIC = 'http://i1.kym-cdn.com/photos/images/original/000/869/487/ccf.png' 4 | export const POLLING_TIME = 20000 5 | 6 | export function addToLocalCache(question) { 7 | let questionsIds = [] 8 | if (localStorage.getItem('questionIds')) { 9 | questionsIds = JSON.parse(localStorage.getItem('questionIds')) 10 | } 11 | questionsIds.push(question.id) 12 | localStorage.setItem('questionIds', JSON.stringify(questionsIds)) 13 | } 14 | 15 | export function isDuplicate(questionId, list) { 16 | return !!list.find(v => v.id === questionId) 17 | } 18 | 19 | export function normalise(name) { 20 | return ( 21 | '@' + name.replace(/\s+/g, '-').replace(/[^\w\s]/gi, '').toLowerCase() 22 | ) 23 | } 24 | 25 | export function getBaseLog(y) { 26 | return Math.ceil(Math.log(Math.pow(y+2, 6))/Math.log(5)/5) 27 | } 28 | 29 | export function flyingHearts(selector) { 30 | let rnd = (min, max) => Math.floor(Math.random()*(max - min + 1) + min) 31 | let id = `heart-${rnd(0, 100)}` 32 | let waves = ['flying1', 'flying2', 'flying3'] 33 | let colors = ['#e91e63', '#642889', '#00cafe', '#144bcb', '#8bc34a'] 34 | let duration = rnd(1000, 2000) 35 | let color = colors[rnd(1, 100) % colors.length] 36 | let size = rnd(20, 50) 37 | let wave = waves[rnd(1, 100) % waves.length] 38 | 39 | $(`
    `) 40 | .appendTo(`${selector}`) 41 | .css({ animation: `${wave} ${duration}ms ease-in-out` }) 42 | $(`.${id}`).show() 43 | setTimeout(() => $(`.${id}`).remove(), duration) 44 | } 45 | 46 | export function setUserDetails(auth) { 47 | return { 48 | idToken: auth.auth0IdToken, 49 | name: 50 | auth.profile.name || 51 | DEFAULT_PROFILE_USERNAME, 52 | username: normalise( 53 | auth.profile.name || 54 | auth.profile.screen_name || 55 | DEFAULT_PROFILE_USERNAME, 56 | ), 57 | profileUrl: 58 | auth.profile.picture || 59 | DEFAULT_PROFILE_PIC, 60 | role: 'User', 61 | } 62 | } 63 | 64 | export let ALERT_DEFAULT = { 65 | position: 'top-right', 66 | effect: 'slide', 67 | offset: 60, 68 | } 69 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require('html-webpack-plugin') 2 | 3 | module.exports = { 4 | entry: ['whatwg-fetch', './src/app.js'], 5 | output: { 6 | publicPath: '/', 7 | }, 8 | module: { 9 | loaders: [ 10 | { 11 | test: /\.js$/, 12 | loader: 'eslint-loader', 13 | exclude: /node_modules/, 14 | enforce: 'pre', 15 | }, 16 | { 17 | test: /\.css/, 18 | loader: 'style-loader!css-loader', 19 | }, 20 | { 21 | test: /\.js$/, 22 | loader: 'babel-loader', 23 | exclude: /node_modules/, 24 | }, 25 | { 26 | test: /\.(graphql|gql)$/, 27 | exclude: /node_modules/, 28 | loader: 'graphql-tag/loader', 29 | }, 30 | ], 31 | }, 32 | plugins: [ 33 | new HtmlWebpackPlugin({ 34 | template: 'src/index.html', 35 | }), 36 | ], 37 | } 38 | -------------------------------------------------------------------------------- /webpack.dist.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const webpack = require("webpack"); 3 | const ExtractTextPlugin = require("extract-text-webpack-plugin"); 4 | 5 | module.exports = { 6 | devtool: "cheap-module-source-map", 7 | entry: "./src/app.js", 8 | output: { 9 | path: path.join(__dirname, "dist"), 10 | filename: "bundle.min.js", 11 | publicPath: "/static/" 12 | }, 13 | module: { 14 | loaders: [ 15 | { 16 | test: /\.js$/, 17 | loaders: ["babel-loader"] 18 | }, 19 | { 20 | test: /\.css$/, 21 | use: ExtractTextPlugin.extract({ 22 | fallback: "style-loader", 23 | use: "css-loader" 24 | }) 25 | }, 26 | { 27 | test: /\.json$/, 28 | loader: "json-loader" 29 | }, 30 | { 31 | test: /\.(jpe?g|png|gif|svg)$/i, 32 | loaders: [ 33 | "file?hash=sha512&digest=hex&name=[hash].[ext]", 34 | "image-webpack?bypassOnDebug&optimizationLevel=7&interlaced=false" 35 | ] 36 | }, 37 | { 38 | test: /\.(graphql|gql)$/, 39 | exclude: /node_modules/, 40 | loader: 'graphql-tag/loader', 41 | }, 42 | ] 43 | }, 44 | plugins: [ 45 | new ExtractTextPlugin("style.css"), 46 | new webpack.DefinePlugin({ 47 | "process.env": { 48 | NODE_ENV: JSON.stringify("production") 49 | } 50 | }) 51 | ] 52 | }; 53 | --------------------------------------------------------------------------------