├── .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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------