├── test
├── test_helper.exs
├── pictionary_web
│ ├── views
│ │ ├── page_view_test.exs
│ │ ├── layout_view_test.exs
│ │ └── error_view_test.exs
│ └── controllers
│ │ └── page_controller_test.exs
└── support
│ ├── channel_case.ex
│ ├── conn_case.ex
│ └── data_case.ex
├── pictionary-app
├── src
│ ├── pages
│ │ ├── Game
│ │ │ └── game.scoped.scss
│ │ ├── GamesList
│ │ │ └── GamesList.scoped.scss
│ │ ├── loading
│ │ │ ├── loading.scoped.scss
│ │ │ └── loading.js
│ │ ├── Home
│ │ │ ├── home.scoped.scss
│ │ │ └── Home.js
│ │ ├── Lobby
│ │ │ └── Lobby.js
│ │ └── NotFound
│ │ │ ├── NotFound.js
│ │ │ └── NotFound.scoped.scss
│ ├── images
│ │ ├── 500.png
│ │ ├── main-background.jpg
│ │ ├── main_background.jpg
│ │ ├── main-background-1.jpg
│ │ ├── main_background-2.jpg
│ │ ├── undo.svg
│ │ ├── pencil.svg
│ │ ├── eraser.svg
│ │ ├── save.svg
│ │ ├── delete.svg
│ │ ├── clock.svg
│ │ └── paint-jar.svg
│ ├── cursors
│ │ ├── fill.png
│ │ ├── eraser.png
│ │ ├── pencil.png
│ │ └── disabled.svg
│ ├── sounds
│ │ ├── fail.mp3
│ │ ├── sound.mp3
│ │ ├── winner.mp3
│ │ ├── clock_tick.mp3
│ │ ├── new_drawer.mp3
│ │ ├── correct_guess.mp3
│ │ ├── new_message.mp3
│ │ ├── player_enter.mp3
│ │ └── player_leave.mp3
│ ├── constants
│ │ ├── loadingTypes.js
│ │ ├── gameToolbarColors.js
│ │ ├── numbers.js
│ │ ├── websocketEvents.js
│ │ ├── avatarStyles.js
│ │ └── actionTypes.js
│ ├── fonts
│ │ └── Laffayette_Comic_Pro.ttf
│ ├── components
│ │ ├── LobbyPlayerDialog
│ │ │ ├── lobbyPlayerDialog.scoped.scss
│ │ │ └── LobbyPlayerDialog.js
│ │ ├── GameVoteKickButton
│ │ │ ├── GameVoteKickButton.scoped.scss
│ │ │ └── GameVoteKickButton.js
│ │ ├── GameOverDialog
│ │ │ ├── GameOverDialog.scoped.scss
│ │ │ └── GameOverDialog.js
│ │ ├── GameWordBox
│ │ │ ├── GameWordBox.scoped.scss
│ │ │ └── GameWordBox.js
│ │ ├── GameWordWasDialog
│ │ │ ├── GameWordWasDialog.scoped.scss
│ │ │ └── GameWordWasDialog.js
│ │ ├── GameCanvas
│ │ │ └── GameCanvas.scoped.scss
│ │ ├── GameHeader
│ │ │ ├── GameHeader.scoped.scss
│ │ │ └── GameHeader.js
│ │ ├── UserInfo
│ │ │ ├── userInfo.scoped.scss
│ │ │ └── userInfo.js
│ │ ├── HomeHeader
│ │ │ ├── HomeHeader.scoped.scss
│ │ │ └── HomeHeader.js
│ │ ├── GameHeaderClock
│ │ │ ├── GameHeaderClock.scoped.scss
│ │ │ └── GameHeaderClock.js
│ │ ├── Avatar
│ │ │ └── Avatar.js
│ │ ├── UserAvatar
│ │ │ └── userAvatar.js
│ │ ├── GameNewRoundDialog
│ │ │ └── GameNewRoundDialog.js
│ │ ├── LobbyPlayersList
│ │ │ ├── LobbyPlayersList.scoped.scss
│ │ │ └── LobbyPlayersList.js
│ │ ├── GameChat
│ │ │ └── GameChat.scoped.scss
│ │ ├── GameToolbar
│ │ │ └── GameToolbar.scoped.scss
│ │ ├── LobbyGameSettings
│ │ │ └── lobbyGameSettings.scoped.scss
│ │ ├── AvatarChooser
│ │ │ ├── avatarChooser.scoped.scss
│ │ │ └── AvatarChooser.js
│ │ ├── GameWordChoiceDialog
│ │ │ └── GameWordChoiceDialog.js
│ │ └── GamePlayersList
│ │ │ ├── GamePlayersList.scoped.scss
│ │ │ └── GamePlayersList.js
│ ├── setupTests.js
│ ├── App.test.js
│ ├── helpers
│ │ ├── api.js
│ │ ├── helpers.js
│ │ └── floodFill.js
│ ├── sagas
│ │ ├── requests
│ │ │ ├── requests.js
│ │ │ └── pictionaryApi.js
│ │ ├── websocket.js
│ │ ├── gameRootSaga.js
│ │ ├── homeRootSaga.js
│ │ ├── lobbyRootSaga.js
│ │ └── handlers
│ │ │ └── gameHandlers.js
│ ├── reportWebVitals.js
│ ├── hooks
│ │ ├── useDarkMode.js
│ │ ├── useAudio.js
│ │ ├── useDidMount.js
│ │ ├── useSfx.js
│ │ └── usePrevious.js
│ ├── reducers
│ │ ├── reducers.js
│ │ ├── userInfo.js
│ │ ├── game.js
│ │ └── settings.js
│ ├── index.css
│ ├── App.scoped.scss
│ ├── hocs
│ │ └── withPlayerCountChangeSfx.js
│ ├── protected_route.js
│ ├── stores
│ │ └── configureStore.js
│ ├── App.js
│ ├── misc
│ │ └── errorBoundary.js
│ ├── index.js
│ └── layout
│ │ └── layout.scss
├── .dockerignore
├── public
│ ├── robots.txt
│ ├── favicon.ico
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── apple-touch-icon.png
│ ├── android-chrome-192x192.png
│ ├── android-chrome-512x512.png
│ ├── manifest.json
│ └── index.html
├── .env.development
├── .env.production
├── craco.config.js
├── Dockerfile
├── jsconfig.json
├── .gitignore
├── package.json
├── .eslintrc
└── README.md
├── priv
├── repo
│ └── migrations
│ │ └── .formatter.exs
└── gettext
│ ├── en
│ └── LC_MESSAGES
│ │ └── errors.po
│ └── errors.pot
├── rel
├── overlays
│ └── bin
│ │ ├── server.bat
│ │ └── server
├── env.sh.eex
├── env.bat.eex
├── vm.args.eex
└── remote.vm.args.eex
├── lib
├── pictionary_web
│ ├── views
│ │ ├── page_view.ex
│ │ ├── layout_view.ex
│ │ ├── user_view.ex
│ │ ├── session_view.ex
│ │ ├── error_view.ex
│ │ ├── game_view.ex
│ │ └── error_helpers.ex
│ ├── presence.ex
│ ├── controllers
│ │ ├── page_controller.ex
│ │ ├── auth
│ │ │ ├── token_auth.ex
│ │ │ └── current_use.ex
│ │ ├── games_controller.ex
│ │ ├── session_controller.ex
│ │ └── users_controller.ex
│ ├── channels
│ │ ├── game_list_channel.ex
│ │ └── user_socket.ex
│ ├── gettext.ex
│ ├── router.ex
│ ├── templates
│ │ ├── page
│ │ │ └── index.html.eex
│ │ └── layout
│ │ │ └── app.html.eex
│ ├── endpoint.ex
│ └── telemetry.ex
├── pictionary.ex
├── pictionary
│ ├── store_supervisor.ex
│ ├── user.ex
│ ├── game_supervisor.ex
│ ├── db_cleaner.ex
│ ├── game.ex
│ ├── application.ex
│ ├── stores
│ │ └── user_store.ex
│ └── game_channel_watcher.ex
└── pictionary_web.ex
├── config
├── prod.exs
├── test.exs
├── config.exs
├── dev.exs
└── runtime.exs
├── .formatter.exs
├── .github
└── FUNDING.yml
├── docker-compose.yml
├── fly.toml
├── .gitignore
├── LICENSE
├── .dockerignore
├── mix.exs
├── Dockerfile
└── README.md
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 |
--------------------------------------------------------------------------------
/pictionary-app/src/pages/Game/game.scoped.scss:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/priv/repo/migrations/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | inputs: ["*.exs"]
3 | ]
4 |
--------------------------------------------------------------------------------
/rel/overlays/bin/server.bat:
--------------------------------------------------------------------------------
1 | set PHX_SERVER=true
2 | call "%~dp0\pictionary" start
3 |
--------------------------------------------------------------------------------
/pictionary-app/.dockerignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 |
3 | /build
4 |
5 | .dockerignore
6 |
7 | Dockerfile
8 |
--------------------------------------------------------------------------------
/pictionary-app/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/lib/pictionary_web/views/page_view.ex:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb.PageView do
2 | use PictionaryWeb, :view
3 | end
4 |
--------------------------------------------------------------------------------
/pictionary-app/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arp-G/pictionary/HEAD/pictionary-app/public/favicon.ico
--------------------------------------------------------------------------------
/pictionary-app/src/images/500.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arp-G/pictionary/HEAD/pictionary-app/src/images/500.png
--------------------------------------------------------------------------------
/rel/overlays/bin/server:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | cd -P -- "$(dirname -- "$0")"
3 | PHX_SERVER=true exec ./pictionary start
4 |
--------------------------------------------------------------------------------
/config/prod.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # Do not print debug messages in production
4 | config :logger, level: :info
5 |
--------------------------------------------------------------------------------
/lib/pictionary_web/views/layout_view.ex:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb.LayoutView do
2 | use PictionaryWeb, :view
3 | end
4 |
--------------------------------------------------------------------------------
/pictionary-app/.env.development:
--------------------------------------------------------------------------------
1 | REACT_APP_SITE_URL=http://localhost:4000
2 | REACT_APP_HOST_URL=http://localhost:3000
3 |
--------------------------------------------------------------------------------
/pictionary-app/src/cursors/fill.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arp-G/pictionary/HEAD/pictionary-app/src/cursors/fill.png
--------------------------------------------------------------------------------
/pictionary-app/src/sounds/fail.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arp-G/pictionary/HEAD/pictionary-app/src/sounds/fail.mp3
--------------------------------------------------------------------------------
/pictionary-app/src/sounds/sound.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arp-G/pictionary/HEAD/pictionary-app/src/sounds/sound.mp3
--------------------------------------------------------------------------------
/pictionary-app/src/cursors/eraser.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arp-G/pictionary/HEAD/pictionary-app/src/cursors/eraser.png
--------------------------------------------------------------------------------
/pictionary-app/src/cursors/pencil.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arp-G/pictionary/HEAD/pictionary-app/src/cursors/pencil.png
--------------------------------------------------------------------------------
/pictionary-app/src/sounds/winner.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arp-G/pictionary/HEAD/pictionary-app/src/sounds/winner.mp3
--------------------------------------------------------------------------------
/pictionary-app/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arp-G/pictionary/HEAD/pictionary-app/public/favicon-16x16.png
--------------------------------------------------------------------------------
/pictionary-app/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arp-G/pictionary/HEAD/pictionary-app/public/favicon-32x32.png
--------------------------------------------------------------------------------
/pictionary-app/src/sounds/clock_tick.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arp-G/pictionary/HEAD/pictionary-app/src/sounds/clock_tick.mp3
--------------------------------------------------------------------------------
/pictionary-app/src/sounds/new_drawer.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arp-G/pictionary/HEAD/pictionary-app/src/sounds/new_drawer.mp3
--------------------------------------------------------------------------------
/pictionary-app/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arp-G/pictionary/HEAD/pictionary-app/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/pictionary-app/src/sounds/correct_guess.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arp-G/pictionary/HEAD/pictionary-app/src/sounds/correct_guess.mp3
--------------------------------------------------------------------------------
/pictionary-app/src/sounds/new_message.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arp-G/pictionary/HEAD/pictionary-app/src/sounds/new_message.mp3
--------------------------------------------------------------------------------
/pictionary-app/src/sounds/player_enter.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arp-G/pictionary/HEAD/pictionary-app/src/sounds/player_enter.mp3
--------------------------------------------------------------------------------
/pictionary-app/src/sounds/player_leave.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arp-G/pictionary/HEAD/pictionary-app/src/sounds/player_leave.mp3
--------------------------------------------------------------------------------
/pictionary-app/.env.production:
--------------------------------------------------------------------------------
1 | REACT_APP_SITE_URL=https://pictionary.fly.dev
2 | REACT_APP_HOST_URL=https://pictionary-game.netlify.app
3 |
--------------------------------------------------------------------------------
/pictionary-app/src/images/main-background.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arp-G/pictionary/HEAD/pictionary-app/src/images/main-background.jpg
--------------------------------------------------------------------------------
/pictionary-app/src/images/main_background.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arp-G/pictionary/HEAD/pictionary-app/src/images/main_background.jpg
--------------------------------------------------------------------------------
/test/pictionary_web/views/page_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb.PageViewTest do
2 | use PictionaryWeb.ConnCase, async: true
3 | end
4 |
--------------------------------------------------------------------------------
/pictionary-app/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arp-G/pictionary/HEAD/pictionary-app/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/pictionary-app/public/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arp-G/pictionary/HEAD/pictionary-app/public/android-chrome-512x512.png
--------------------------------------------------------------------------------
/pictionary-app/src/constants/loadingTypes.js:
--------------------------------------------------------------------------------
1 | export default ['balls', 'bars', 'bubbles', 'cubes', 'cylon', 'spin', 'spinningBubbles', 'spokes'];
2 |
--------------------------------------------------------------------------------
/pictionary-app/src/images/main-background-1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arp-G/pictionary/HEAD/pictionary-app/src/images/main-background-1.jpg
--------------------------------------------------------------------------------
/pictionary-app/src/images/main_background-2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arp-G/pictionary/HEAD/pictionary-app/src/images/main_background-2.jpg
--------------------------------------------------------------------------------
/pictionary-app/src/fonts/Laffayette_Comic_Pro.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arp-G/pictionary/HEAD/pictionary-app/src/fonts/Laffayette_Comic_Pro.ttf
--------------------------------------------------------------------------------
/rel/env.sh.eex:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | ip=$(grep fly-local-6pn /etc/hosts | cut -f 1)
4 | export RELEASE_DISTRIBUTION=name
5 | export RELEASE_NODE=$FLY_APP_NAME@$ip
6 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/LobbyPlayerDialog/lobbyPlayerDialog.scoped.scss:
--------------------------------------------------------------------------------
1 | .closeButton {
2 | position: absolute;
3 | margin-left: 87%;
4 | top: 5px;
5 | }
6 |
--------------------------------------------------------------------------------
/lib/pictionary_web/presence.ex:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb.Presence do
2 | use Phoenix.Presence,
3 | otp_app: :pictionary,
4 | pubsub_server: Pictionary.PubSub
5 | end
6 |
--------------------------------------------------------------------------------
/pictionary-app/craco.config.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable global-require */
2 | module.exports = {
3 | plugins: [
4 | { plugin: require('craco-plugin-scoped-css') }
5 | ]
6 | };
7 |
--------------------------------------------------------------------------------
/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:phoenix],
3 | inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"],
4 | subdirectories: ["priv/*/migrations"]
5 | ]
6 |
--------------------------------------------------------------------------------
/lib/pictionary_web/controllers/page_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb.PageController do
2 | use PictionaryWeb, :controller
3 |
4 | def health(conn, _params) do
5 | json(conn, %{status: "heathy"})
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/rel/env.bat.eex:
--------------------------------------------------------------------------------
1 | @echo off
2 | rem Set the release to work across nodes.
3 | rem RELEASE_DISTRIBUTION must be "sname" (local), "name" (distributed) or "none".
4 | rem set RELEASE_DISTRIBUTION=name
5 | rem set RELEASE_NODE=<%= @release.name %>
6 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | # If you like my work or want to support the project, please consider sponsoring
4 |
5 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
6 | custom: ['paypal.me/arprokz']
--------------------------------------------------------------------------------
/lib/pictionary_web/views/user_view.ex:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb.UserView do
2 | use PictionaryWeb, :view
3 |
4 | def render("show.json", %{user: user}) do
5 | %{
6 | id: user.id,
7 | name: user.name,
8 | avatar: user.avatar
9 | }
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/pictionary-app/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/lib/pictionary_web/channels/game_list_channel.ex:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb.GameListChannel do
2 | use Phoenix.Channel
3 | alias Pictionary.Stores.GameStore
4 |
5 | def join("game_stats", _payload, socket) do
6 | {:ok, %{game_stats: GameStore.list_games()}, socket}
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/pictionary-app/src/pages/GamesList/GamesList.scoped.scss:
--------------------------------------------------------------------------------
1 | .gamesListWrapper {
2 | }
3 |
4 | .emptyGamesList {
5 | .icon {
6 | padding-top: 25px;
7 | font-size: 3em;
8 | }
9 |
10 | text-align: center;
11 | min-height: 100px;
12 | width: 50%;
13 | margin: auto;
14 | }
--------------------------------------------------------------------------------
/test/pictionary_web/controllers/page_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb.PageControllerTest do
2 | use PictionaryWeb.ConnCase
3 |
4 | test "GET /", %{conn: conn} do
5 | conn = get(conn, "/")
6 | assert html_response(conn, 200) =~ "Welcome to Phoenix!"
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/GameVoteKickButton/GameVoteKickButton.scoped.scss:
--------------------------------------------------------------------------------
1 | .voteToKickButtonContainer {
2 | .Mui-disabled {
3 | background-color: lightgrey !important;
4 | opacity: 1 !important;
5 |
6 | .MuiButton-label {
7 | color: black;
8 | }
9 | }
10 | }
--------------------------------------------------------------------------------
/lib/pictionary.ex:
--------------------------------------------------------------------------------
1 | defmodule Pictionary do
2 | @moduledoc """
3 | Pictionary keeps the contexts that define your domain
4 | and business logic.
5 |
6 | Contexts are also responsible for managing your data, regardless
7 | if it comes from the database, an external API or others.
8 | """
9 | end
10 |
--------------------------------------------------------------------------------
/pictionary-app/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:14
2 |
3 | # set working directory to app
4 | WORKDIR /app
5 |
6 | # install app dependencies
7 | COPY package.json ./
8 | COPY package-lock.json ./
9 | RUN npm install
10 |
11 | # copy source code to app
12 | COPY . ./
13 |
14 | # start the application
15 | CMD ["npm", "start"]
16 |
--------------------------------------------------------------------------------
/config/test.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # We don't run a server during test. If one is required,
4 | # you can enable the server option below.
5 | config :pictionary, PictionaryWeb.Endpoint,
6 | http: [port: 4002],
7 | server: false
8 |
9 | # Print only warnings and errors during test
10 | config :logger, level: :warn
11 |
--------------------------------------------------------------------------------
/pictionary-app/src/pages/loading/loading.scoped.scss:
--------------------------------------------------------------------------------
1 | .loadingWrapper {
2 | text-align: center;
3 | margin: auto;
4 | position: relative;
5 | top: 80%;
6 | }
7 |
8 | .loadingText {
9 | font-weight: bolder;
10 | font-size: 3em;
11 | color: white;
12 | }
13 |
14 | .loadingAnimation {
15 | margin: auto;
16 | }
17 |
--------------------------------------------------------------------------------
/pictionary-app/src/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 | import App from './App';
4 |
5 | test('renders learn react link', () => {
6 | render( );
7 | const linkElement = screen.getByText(/learn react/i);
8 | expect(linkElement).toBeInTheDocument();
9 | });
10 |
--------------------------------------------------------------------------------
/pictionary-app/src/helpers/api.js:
--------------------------------------------------------------------------------
1 | export const SITE_URL = process.env.REACT_APP_SITE_URL || 'http://localhost:4000';
2 | export const HOST_URL = process.env.REACT_APP_HOST_URL || 'http://localhost:3000';
3 | export const API = `${SITE_URL}/api`;
4 | export const WEBSOCKET_API = `${SITE_URL.replace('http', 'ws').replace('https', 'ws')}/socket`;
5 |
--------------------------------------------------------------------------------
/test/pictionary_web/views/layout_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb.LayoutViewTest do
2 | use PictionaryWeb.ConnCase, async: true
3 |
4 | # When testing helpers, you may want to import Phoenix.HTML and
5 | # use functions such as safe_to_string() to convert the helper
6 | # result into an HTML string.
7 | # import Phoenix.HTML
8 | end
9 |
--------------------------------------------------------------------------------
/pictionary-app/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "./",
4 | "paths": {
5 | "@components/*": ["./src/components/*"],
6 | "@pages/*": ["./src/pages/*"],
7 | "@constants/*": ["./src/constants/*"],
8 | "@sounds/*": ["./src/sounds/*"]
9 | }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/GameOverDialog/GameOverDialog.scoped.scss:
--------------------------------------------------------------------------------
1 | .gameOverTitle {
2 | text-align: center;
3 | font-weight: bolder;
4 | font-size: larger;
5 | }
6 |
7 | .winnerContainer {
8 | width: 100%;
9 | height: 100%;
10 | text-align: center;
11 | }
12 |
13 | .winnerName {
14 | font-size: small;
15 | padding: 5px;
16 | }
17 |
--------------------------------------------------------------------------------
/pictionary-app/src/sagas/requests/requests.js:
--------------------------------------------------------------------------------
1 | import pictionaryApi from './pictionaryApi';
2 |
3 | export const createUserSession = sessionId => pictionaryApi.post('sessions', sessionId);
4 | export const getUserData = () => pictionaryApi.get('users');
5 | export const createGame = () => pictionaryApi.post('games');
6 | export const getGame = gameId => pictionaryApi.get(`games/${gameId}`);
7 |
--------------------------------------------------------------------------------
/pictionary-app/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/pictionary-app/src/pages/Home/home.scoped.scss:
--------------------------------------------------------------------------------
1 | .wrapped-paper {
2 | text-align: center;
3 | padding: 15px 50px 15px 50px;
4 | margin: 20px;
5 | }
6 |
7 | .info-wrapper {
8 | text-align: center;
9 | margin: 0 20px 0 20px;
10 | }
11 |
12 | .header {
13 | transition: all 0.3s ease;
14 | }
15 |
16 | .info-text {
17 | text-align: center;
18 | font-family: "Times New Roman", Times, serif;
19 | }
20 |
--------------------------------------------------------------------------------
/rel/vm.args.eex:
--------------------------------------------------------------------------------
1 | ## Customize flags given to the VM: https://erlang.org/doc/man/erl.html
2 | ## -mode/-name/-sname/-setcookie are configured via env vars, do not set them here
3 |
4 | ## Number of dirty schedulers doing IO work (file, sockets, and others)
5 | ##+SDio 5
6 |
7 | ## Increase number of concurrent ports/sockets
8 | ##+Q 65536
9 |
10 | ## Tweak GC to run more often
11 | ##-env ERL_FULLSWEEP_AFTER 10
12 |
--------------------------------------------------------------------------------
/rel/remote.vm.args.eex:
--------------------------------------------------------------------------------
1 | ## Customize flags given to the VM: https://erlang.org/doc/man/erl.html
2 | ## -mode/-name/-sname/-setcookie are configured via env vars, do not set them here
3 |
4 | ## Number of dirty schedulers doing IO work (file, sockets, and others)
5 | ##+SDio 5
6 |
7 | ## Increase number of concurrent ports/sockets
8 | ##+Q 65536
9 |
10 | ## Tweak GC to run more often
11 | ##-env ERL_FULLSWEEP_AFTER 10
12 |
--------------------------------------------------------------------------------
/pictionary-app/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/lib/pictionary/store_supervisor.ex:
--------------------------------------------------------------------------------
1 | defmodule Pictionary.StoreSupervisor do
2 | use Supervisor
3 | alias Pictionary.Stores.{UserStore, GameStore}
4 |
5 | def start_link(_state) do
6 | Supervisor.start_link(__MODULE__, nil, name: __MODULE__)
7 | end
8 |
9 | def init(_init_arg) do
10 | children = [{UserStore, [:ok]}, {GameStore, [:ok]}]
11 |
12 | Supervisor.init(children, strategy: :one_for_one)
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/pictionary_web/controllers/auth/token_auth.ex:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb.TokenAuth do
2 | use PictionaryWeb, :controller
3 |
4 | def init(opts), do: opts
5 |
6 | def call(%Plug.Conn{assigns: %{current_user: %Pictionary.User{id: _id}}} = conn, _opts),
7 | do: conn
8 |
9 | def call(conn, _opts) do
10 | conn
11 | |> put_status(:unauthorized)
12 | |> json(%{error: "Unauthorized"})
13 | |> halt()
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/pictionary_web/views/session_view.ex:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb.SessionView do
2 | use PictionaryWeb, :view
3 |
4 | def render("create.json", %{user: user, token: token}) do
5 | %{
6 | token: token,
7 | user: render_one(user, PictionaryWeb.UserView, "show.json")
8 | }
9 | end
10 |
11 | def render("show.json", user) do
12 | %{
13 | name: user.name,
14 | avatar: user.avatar
15 | }
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/pictionary-app/src/reportWebVitals.js:
--------------------------------------------------------------------------------
1 | const reportWebVitals = (onPerfEntry) => {
2 | if (onPerfEntry && onPerfEntry instanceof Function) {
3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
4 | getCLS(onPerfEntry);
5 | getFID(onPerfEntry);
6 | getFCP(onPerfEntry);
7 | getLCP(onPerfEntry);
8 | getTTFB(onPerfEntry);
9 | });
10 | }
11 | };
12 |
13 | export default reportWebVitals;
14 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/GameWordBox/GameWordBox.scoped.scss:
--------------------------------------------------------------------------------
1 | .alphabetGuessContainer {
2 | height: 80px;
3 | line-height: 80px;
4 | text-align: right;
5 | padding-right: 100px;;
6 | }
7 |
8 | .alphabetGuess, .alphabetGuessSpace {
9 | display: inline-block;
10 | padding: 5px;
11 | font-weight: bolder;
12 | font-size: 1.5em;
13 | text-decoration: underline;
14 | }
15 |
16 | .alphabetGuessSpace {
17 | text-decoration: none;
18 | }
19 |
--------------------------------------------------------------------------------
/pictionary-app/src/constants/gameToolbarColors.js:
--------------------------------------------------------------------------------
1 | export default [
2 | '#FFF',
3 | '#C1C1C1',
4 | '#EF130B',
5 | '#FF7100',
6 | '#FFE400',
7 | '#00CC00',
8 | '#00B2FF',
9 | '#231FD3',
10 | '#A300BA',
11 | '#D37CAA',
12 | '#A0522D',
13 | '#000',
14 | '#4C4C4C',
15 | '#740B07',
16 | '#C23800',
17 | '#E8A200',
18 | '#005510',
19 | '#00569E',
20 | '#0E0865',
21 | '#550069',
22 | '#A75574',
23 | '#63300D'
24 | ];
25 |
--------------------------------------------------------------------------------
/pictionary-app/src/constants/numbers.js:
--------------------------------------------------------------------------------
1 | export default {
2 | 1: 'One',
3 | 2: 'Two',
4 | 3: 'Three',
5 | 4: 'Four',
6 | 5: 'Five',
7 | 6: 'Six',
8 | 7: 'Seven',
9 | 8: 'Eight',
10 | 9: 'Nine',
11 | 10: 'Ten',
12 | 11: 'Eleven',
13 | 12: 'Twelve',
14 | 13: 'Thirteen',
15 | 14: 'Fourteen',
16 | 15: 'Fifteen',
17 | 16: 'Sixteen',
18 | 17: 'Seventeen',
19 | 18: 'Eighteen',
20 | 19: 'Nineteen',
21 | 20: 'Twenty'
22 | };
23 |
--------------------------------------------------------------------------------
/pictionary-app/src/sagas/websocket.js:
--------------------------------------------------------------------------------
1 | import { Socket } from 'phoenix';
2 | import { WEBSOCKET_API } from '../helpers/api';
3 |
4 | export default (token) => {
5 | const socket = new Socket(WEBSOCKET_API,
6 | {
7 | params: { token },
8 | logger: (kind, msg, data) => (
9 | // eslint-disable-next-line no-console
10 | console.log(`${kind}: ${msg}`, data)
11 | )
12 | }
13 | );
14 | socket.connect();
15 | return socket;
16 | };
17 |
--------------------------------------------------------------------------------
/pictionary-app/src/hooks/useDarkMode.js:
--------------------------------------------------------------------------------
1 | import { useDispatch, useSelector } from 'react-redux';
2 | import { TOGGLE_DARK_MODE } from '../constants/actionTypes';
3 |
4 | export default () => {
5 | const dispatch = useDispatch();
6 | const darkMode = useSelector(state => state.settings.darkMode);
7 |
8 | const saveTheme = () => {
9 | window.localStorage.setItem('userTheme', !darkMode);
10 | dispatch({ type: TOGGLE_DARK_MODE });
11 | };
12 |
13 | return [darkMode, saveTheme];
14 | };
15 |
--------------------------------------------------------------------------------
/pictionary-app/src/reducers/reducers.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import { connectRouter } from 'connected-react-router';
3 | import userInfo from './userInfo';
4 | import settings from './settings';
5 | import game from './game';
6 | import gamePlay from './gamePlay';
7 |
8 | const createRootReducer = history => combineReducers({
9 | settings,
10 | userInfo,
11 | game,
12 | gamePlay,
13 | router: connectRouter(history)
14 | });
15 |
16 | export default createRootReducer;
17 |
--------------------------------------------------------------------------------
/test/pictionary_web/views/error_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb.ErrorViewTest do
2 | use PictionaryWeb.ConnCase, async: true
3 |
4 | # Bring render/3 and render_to_string/3 for testing custom views
5 | import Phoenix.View
6 |
7 | test "renders 404.html" do
8 | assert render_to_string(PictionaryWeb.ErrorView, "404.html", []) == "Not Found"
9 | end
10 |
11 | test "renders 500.html" do
12 | assert render_to_string(PictionaryWeb.ErrorView, "500.html", []) == "Internal Server Error"
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/pictionary-app/src/pages/loading/loading.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactLoading from 'react-loading';
3 | import loadingAnimations from '../../constants/loadingTypes';
4 | import { getRandomItem } from '../../helpers/helpers';
5 | import './loading.scoped.scss';
6 |
7 | const Loading = () => (
8 |
9 |
10 |
11 |
12 | );
13 |
14 | export default Loading;
15 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/GameWordWasDialog/GameWordWasDialog.scoped.scss:
--------------------------------------------------------------------------------
1 | .wordWas {
2 | font-weight: bolder;
3 | }
4 |
5 | .playerScoreContainer {
6 | overflow: hidden;
7 |
8 | .playerScore {
9 | text-align: center;
10 | }
11 |
12 | .guessed-correctly {
13 | color: green;
14 | }
15 |
16 | .could-not-guess {
17 | color: red;
18 | }
19 |
20 | .playerName {
21 | max-width: 5rem;
22 | overflow: hidden;
23 | text-align: center;
24 | font-size: medium;
25 | font-family: Arial, Helvetica, sans-serif;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/lib/pictionary/user.ex:
--------------------------------------------------------------------------------
1 | defmodule Pictionary.User do
2 | # Implement Jason.Encoder protocol to encode user structs for presence updates
3 | @derive {Jason.Encoder, only: [:id, :name, :avatar]}
4 | defstruct([
5 | {:id, Nanoid.generate()},
6 | :name,
7 | :avatar,
8 | created_at: DateTime.utc_now(),
9 | updated_at: DateTime.utc_now()
10 | ])
11 |
12 | @type t :: %__MODULE__{
13 | id: String.t(),
14 | name: String.t(),
15 | avatar: map(),
16 | created_at: t(),
17 | updated_at: t()
18 | }
19 | end
20 |
--------------------------------------------------------------------------------
/pictionary-app/src/hooks/useAudio.js:
--------------------------------------------------------------------------------
1 | import { useSelector } from 'react-redux';
2 |
3 | export default (audioPath, force = false) => {
4 | const soundEnabled = useSelector(state => state.settings.sound);
5 | // console.log("REFRESHING CUSOTM HOOK " + soundEnabled);
6 | const audioElement = new Audio(audioPath);
7 |
8 | const playAudio = () => {
9 | if (soundEnabled || force) {
10 | // eslint-disable-next-line no-console
11 | console.log(`Playing Sound ${audioPath}`);
12 | audioElement.play();
13 | }
14 | };
15 |
16 | return playAudio;
17 | };
18 |
--------------------------------------------------------------------------------
/pictionary-app/src/index.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: "Laffayette_Comic_Pro";
3 | src: local("Laffayette_Comic_Pro"),
4 | url("./fonts/Laffayette_Comic_Pro.ttf") format("truetype");
5 | font-weight: normal;
6 | }
7 |
8 | body {
9 | margin: 0;
10 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
11 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
12 | sans-serif;
13 | -webkit-font-smoothing: antialiased;
14 | -moz-osx-font-smoothing: grayscale;
15 | font-family: "Laffayette_Comic_Pro"
16 | }
17 |
18 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/GameCanvas/GameCanvas.scoped.scss:
--------------------------------------------------------------------------------
1 | .canvasContainer {
2 | height: 62vh;
3 | background-color: white;
4 | max-width: 1125px;
5 | margin: auto;
6 | border-radius: 5px;
7 | }
8 |
9 | .canvas-pen {
10 | cursor: url("../../cursors/pencil.png") -12 46, crosshair;
11 | }
12 |
13 | .canvas-eraser {
14 | cursor: url("../../cursors/eraser.png") 5 43, crosshair;
15 | }
16 |
17 | .canvas-fill {
18 | cursor: url("../../cursors/fill.png") 3 20, crosshair;
19 | }
20 |
21 | .canvas-disabled {
22 | cursor: url("../../cursors/disabled.svg") 16 16, crosshair;
23 | }
--------------------------------------------------------------------------------
/lib/pictionary_web/views/error_view.ex:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb.ErrorView do
2 | use PictionaryWeb, :view
3 |
4 | # If you want to customize a particular status code
5 | # for a certain format, you may uncomment below.
6 | # def render("500.html", _assigns) do
7 | # "Internal Server Error"
8 | # end
9 |
10 | # By default, Phoenix returns the status message from
11 | # the template name. For example, "404.html" becomes
12 | # "Not Found".
13 | def template_not_found(template, _assigns) do
14 | Phoenix.Controller.status_message_from_template(template)
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/pictionary-app/src/hooks/useDidMount.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 |
3 | /*
4 | Run some code on every subsequest dependency changes(dependencies specified in "deps") but don't run code on mount
5 | This hook uses the "useRef" hook to keep track of initial mount of the component
6 | */
7 |
8 | const useDidMount = (func, deps) => {
9 | const didMount = useRef(false);
10 |
11 | useEffect(() => {
12 | // Run code if its not the first render/mount
13 | if (didMount.current) func();
14 | else didMount.current = true;
15 | }, deps);
16 | };
17 |
18 | export default useDidMount;
19 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/GameHeader/GameHeader.scoped.scss:
--------------------------------------------------------------------------------
1 | .gameHeader {
2 | min-height: 60px;
3 | background-color: beige;
4 | width: 100%;
5 | }
6 |
7 | .gameRoundText{
8 | height: 80px;
9 | line-height: 80px;
10 | font-family:'Franklin Gothic Medium', 'Arial Narrow', Arial, sans-serif;
11 | font-size: 1.2em;
12 | font-weight: bold;
13 | }
14 |
15 | .saveSketch {
16 | margin-top: 80%;
17 | }
18 |
19 | .fullScreenWrapper {
20 | margin-left: 20px;
21 | }
22 |
23 | .fullScreen {
24 | margin-top: 85%;
25 | font-size: 2em;
26 | }
27 |
28 | .saveSketch:hover {
29 | transform: scale(1.1);
30 | cursor: pointer;
31 | }
32 |
--------------------------------------------------------------------------------
/pictionary-app/src/hooks/useSfx.js:
--------------------------------------------------------------------------------
1 | import { useDispatch, useSelector } from 'react-redux';
2 | import useAudio from './useAudio';
3 | import soundToggleSfx from '../sounds/sound.mp3';
4 | import { TOGGLE_SOUND } from '../constants/actionTypes';
5 |
6 | export default () => {
7 | const dispatch = useDispatch();
8 | const sound = useSelector(state => state.settings.sound);
9 | const playSoundSfx = useAudio(soundToggleSfx, true);
10 |
11 | const saveSound = () => {
12 | window.localStorage.setItem('userSound', !sound);
13 | dispatch({ type: TOGGLE_SOUND });
14 | playSoundSfx();
15 | };
16 |
17 | return [sound, saveSound];
18 | };
19 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | phoenix:
4 | build:
5 | context: .
6 | dockerfile: Dockerfile
7 | volumes:
8 | - .:/app
9 | - elixir-deps:/app/deps
10 | - elixir-build:/app/_build
11 | tty: true
12 | stdin_open: true
13 | ports:
14 | - "4000:4000"
15 | react:
16 | build:
17 | context: ./pictionary-app
18 | dockerfile: Dockerfile
19 | volumes:
20 | - ./pictionary-app:/app
21 | - node-modules:/app/node_modules
22 | ports:
23 | - 3000:3000
24 | depends_on:
25 | - phoenix
26 | volumes:
27 | elixir-deps:
28 | elixir-build:
29 | node-modules:
30 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/UserInfo/userInfo.scoped.scss:
--------------------------------------------------------------------------------
1 | .avatarSettingsIcon {
2 | position: relative;
3 | left: 80px;
4 | top: 35px;
5 | transition: all 0.3s ease;
6 | }
7 |
8 | .userAvatarIcon {
9 | position: relative;
10 | left: 80px;
11 | top: 20px;
12 | transition: all 0.3s ease;
13 | }
14 |
15 | .userAvatarIcon:hover,
16 | .avatarSettingsIcon:hover {
17 | transform: scale(1.3);
18 | color: red;
19 | }
20 |
21 | .avatarChooserModal {
22 | display: "flex";
23 | align-items: "center";
24 | justify-content: "center";
25 | }
26 |
27 | @media only screen and (max-device-width: 480px) {
28 | .MuiButton-label {
29 | font-size: 0.6em;
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/HomeHeader/HomeHeader.scoped.scss:
--------------------------------------------------------------------------------
1 | .headerText {
2 | font-size: 3em;
3 | padding-top: 10px;
4 | padding-bottom: 10px;
5 | background-image: linear-gradient(45deg, #090979, #00d4ff);
6 | -webkit-background-clip: text;
7 | -webkit-text-fill-color: transparent;
8 | -moz-background-clip: text;
9 | -moz-text-fill-color: transparent;
10 | }
11 |
12 | .darkMode {
13 | .headerText {
14 | background-color: #ef5734;
15 | background-image: linear-gradient(315deg, #ef5734 0%, #ffcc2f 74%);
16 | }
17 | }
18 |
19 | @media only screen and (max-device-width: 480px) {
20 | .headerAvatars {
21 | display: none;
22 | }
23 |
24 | .headerText {
25 | font-size: 2em;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/pictionary-app/src/sagas/requests/pictionaryApi.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable quote-props */
2 | import axios from 'axios';
3 | import { API } from '../../helpers/api';
4 | import { getTokenFromLocalStorage } from '../../helpers/helpers';
5 |
6 | const axiosInstance = axios.create({ baseURL: API });
7 | const getDefaultHeaders = () => ({
8 | 'Content-type': 'application/json;',
9 | 'Authorization': getTokenFromLocalStorage() ? `Bearer ${getTokenFromLocalStorage()}` : ''
10 | });
11 |
12 | axiosInstance.interceptors.request.use((config) => {
13 | // eslint-disable-next-line no-param-reassign
14 | config.headers = { ...config.header, ...getDefaultHeaders() };
15 | return config;
16 | }, err => Promise.reject(err));
17 |
18 | export default axiosInstance;
19 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/GameHeaderClock/GameHeaderClock.scoped.scss:
--------------------------------------------------------------------------------
1 | .gameClock {
2 | // Clock text font
3 | font-weight: bolder;
4 | font-family: 'Courier New', Courier, monospace;
5 |
6 | // Center text inside clock
7 | display: flex;
8 | justify-content: center;
9 | flex-direction: column;
10 | text-align: center;
11 |
12 | // clock background image
13 | background-image: url("./../../images/clock.svg");
14 | background-repeat: no-repeat;
15 | background-size: cover;
16 | width: 3.5em;
17 | height: 3.5em;
18 |
19 | transition: color 2s ease;
20 | margin-left: 10px;
21 | margin-top: 10px;
22 | }
23 |
24 | .gameClock-red { color: #A30000 }
25 | .gameClock-yellow { color: #F7B801 }
26 | .gameClock-green { color: #008000 }
27 |
--------------------------------------------------------------------------------
/pictionary-app/src/App.scoped.scss:
--------------------------------------------------------------------------------
1 | .App {
2 | text-align: center;
3 | }
4 |
5 | .App-header {
6 | background-color: #282c34;
7 | min-height: 100vh;
8 | display: flex;
9 | flex-direction: column;
10 | align-items: center;
11 | justify-content: center;
12 | font-size: calc(10px + 2vmin);
13 | color: white;
14 | }
15 |
16 | .App-link {
17 | color: #61dafb;
18 | }
19 |
20 | // Global scrollbar styling
21 |
22 | /* width */
23 | ::-webkit-scrollbar {
24 | width: 10px;
25 | }
26 |
27 | /* Track */
28 | ::-webkit-scrollbar-track {
29 | background: #f1f1f1;
30 | }
31 |
32 | /* Handle */
33 | ::-webkit-scrollbar-thumb {
34 | background: #888;
35 | }
36 |
37 | /* Handle on hover */
38 | ::-webkit-scrollbar-thumb:hover {
39 | background: #555;
40 | }
41 |
--------------------------------------------------------------------------------
/lib/pictionary/game_supervisor.ex:
--------------------------------------------------------------------------------
1 | defmodule Pictionary.GameSupervisor do
2 | use DynamicSupervisor
3 | require Logger
4 |
5 | def start_link(_) do
6 | DynamicSupervisor.start_link(__MODULE__, :no_args, name: __MODULE__)
7 | end
8 |
9 | def init(:no_args) do
10 | DynamicSupervisor.init(strategy: :one_for_one)
11 | end
12 |
13 | def add_game_server(game_id) do
14 | {:ok, pid} = DynamicSupervisor.start_child(__MODULE__, {Pictionary.GameServer, game_id})
15 | pid
16 | end
17 |
18 | def remove_game_server(nil), do: Logger.info("Ignoring stop request")
19 |
20 | def remove_game_server(child_pid) do
21 | DynamicSupervisor.terminate_child(__MODULE__, child_pid)
22 | Logger.info("Stopped game server #{inspect(child_pid)}")
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/lib/pictionary/db_cleaner.ex:
--------------------------------------------------------------------------------
1 | # Periodically cleans and removes expired games and users
2 | defmodule Pictionary.DBCleaner do
3 | use GenServer
4 |
5 | # 1 month
6 | @interval 2_629_800_000
7 |
8 | def start_link(_opts) do
9 | GenServer.start_link(__MODULE__, %{})
10 | end
11 |
12 | def init(state) do
13 | # Schedule work to be performed at some point
14 | schedule_cleanup()
15 | {:ok, state}
16 | end
17 |
18 | def handle_info(:work, state) do
19 | # Do the work you desire here
20 | # Reschedule once more
21 | schedule_cleanup()
22 | {:noreply, state}
23 | end
24 |
25 | defp schedule_cleanup() do
26 | Pictionary.Stores.GameStore.remove_old_records()
27 | Pictionary.Stores.UserStore.remove_old_records()
28 | Process.send_after(self(), :work, @interval)
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/lib/pictionary_web/gettext.ex:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb.Gettext do
2 | @moduledoc """
3 | A module providing Internationalization with a gettext-based API.
4 |
5 | By using [Gettext](https://hexdocs.pm/gettext),
6 | your module gains a set of macros for translations, for example:
7 |
8 | import PictionaryWeb.Gettext
9 |
10 | # Simple translation
11 | gettext("Here is the string to translate")
12 |
13 | # Plural translation
14 | ngettext("Here is the string to translate",
15 | "Here are the strings to translate",
16 | 3)
17 |
18 | # Domain-based translation
19 | dgettext("errors", "Here is the error message to translate")
20 |
21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
22 | """
23 | use Gettext, otp_app: :pictionary
24 | end
25 |
--------------------------------------------------------------------------------
/pictionary-app/src/pages/Lobby/Lobby.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useSelector } from 'react-redux';
3 | import { Redirect } from 'react-router-dom';
4 | import { Grid } from '@material-ui/core';
5 | import LobbyGameSettings from '../../components/LobbyGameSettings/LobbyGameSettings';
6 | import LobbyPlayersList from '../../components/LobbyPlayersList/LobbyPlayersList';
7 |
8 | const Lobby = () => {
9 | const gameId = useSelector(state => state.game.id);
10 |
11 | return gameId === null
12 | ?
13 | : (
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | );
23 | };
24 |
25 | export default Lobby;
26 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/Avatar/Avatar.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Avataaars from 'avataaars';
3 |
4 | const Avatar = ({ avatarStyles, width, height, transparent = true }) => (
5 |
20 | );
21 |
22 | export default Avatar;
23 |
--------------------------------------------------------------------------------
/fly.toml:
--------------------------------------------------------------------------------
1 | # fly.toml file generated for pictionary on 2023-01-02T22:42:35+05:30
2 |
3 | app = "pictionary"
4 | kill_signal = "SIGTERM"
5 | kill_timeout = 5
6 | processes = []
7 |
8 | [env]
9 | PHX_HOST = "pictionary.fly.dev"
10 | PORT = "8080"
11 |
12 | [experimental]
13 | allowed_public_ports = []
14 | auto_rollback = true
15 |
16 | [[services]]
17 | http_checks = []
18 | internal_port = 8080
19 | processes = ["app"]
20 | protocol = "tcp"
21 | script_checks = []
22 | [services.concurrency]
23 | hard_limit = 25
24 | soft_limit = 20
25 | type = "connections"
26 |
27 | [[services.ports]]
28 | force_https = true
29 | handlers = ["http"]
30 | port = 80
31 |
32 | [[services.ports]]
33 | handlers = ["tls", "http"]
34 | port = 443
35 |
36 | [[services.tcp_checks]]
37 | grace_period = "1s"
38 | interval = "15s"
39 | restart_limit = 0
40 | timeout = "2s"
41 |
--------------------------------------------------------------------------------
/lib/pictionary_web/controllers/games_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb.GamesController do
2 | use PictionaryWeb, :controller
3 | alias Pictionary.Game
4 | alias Pictionary.Stores.GameStore
5 |
6 | def create(%{assigns: %{current_user: current_user}} = conn, _params) do
7 | game = %Game{
8 | id: Nanoid.generate(),
9 | players: MapSet.new([current_user.id]),
10 | creator_id: current_user.id,
11 | created_at: DateTime.utc_now(),
12 | updated_at: DateTime.utc_now()
13 | }
14 |
15 | GameStore.add_game(game)
16 | render(conn, "show.json", game: game)
17 | end
18 |
19 | def show(conn, %{"game_id" => game_id}) do
20 | game = GameStore.get_game(game_id)
21 |
22 | if game do
23 | render(conn, "show.json", game: GameStore.get_game(game_id))
24 | else
25 | conn
26 | |> Plug.Conn.put_status(:not_found)
27 | |> json(%{error: "Game not found"})
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/pictionary-app/src/pages/NotFound/NotFound.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/jsx-one-expression-per-line */
2 | /* eslint-disable react/button-has-type */
3 | /* eslint-disable react/no-unescaped-entities */
4 | /*
5 | This 404 page is taken from https://codepen.io/linux/pen/OjmeKP
6 | since I suck at css
7 | */
8 | import React from 'react';
9 | import { Link } from 'react-router-dom';
10 | import { FaQuestionCircle } from 'react-icons/fa';
11 | import './NotFound.scoped.scss';
12 |
13 | const NotFound = () => (
14 |
15 |
4
16 |
17 |
4
18 |
19 | Maybe this page moved? Got deleted? Is hiding out in quarantine? Never existed in the first place?
20 |
21 | Let's go home and try from there.
22 |
23 |
24 |
25 | );
26 |
27 | export default NotFound;
28 |
--------------------------------------------------------------------------------
/lib/pictionary/game.ex:
--------------------------------------------------------------------------------
1 | defmodule Pictionary.Game do
2 | defstruct(
3 | id: Nanoid.generate(),
4 | rounds: 3,
5 | time: 80,
6 | max_players: 10,
7 | custom_words: [],
8 | custom_words_probability: 50,
9 | public_game: true,
10 | vote_kick_enabled: true,
11 | players: MapSet.new(),
12 | started: false,
13 | creator_id: nil,
14 | created_at: DateTime.utc_now(),
15 | updated_at: DateTime.utc_now()
16 | )
17 |
18 | @type t :: %__MODULE__{
19 | id: String.t(),
20 | rounds: number(),
21 | time: number(),
22 | max_players: number(),
23 | custom_words: list(),
24 | custom_words_probability: number(),
25 | public_game: boolean(),
26 | vote_kick_enabled: boolean(),
27 | players: MapSet.t(),
28 | creator_id: String.t(),
29 | created_at: t(),
30 | updated_at: t(),
31 | started: boolean()
32 | }
33 | end
34 |
--------------------------------------------------------------------------------
/lib/pictionary_web/controllers/session_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb.SessionController do
2 | use PictionaryWeb, :controller
3 | alias Pictionary.{Stores.UserStore, User}
4 |
5 | def create(%{assigns: %{current_user: user}} = conn, %{"name" => name, "avatar" => avatar}) do
6 | user =
7 | if user,
8 | do: %User{user | name: name, avatar: avatar},
9 | else: %User{
10 | id: Nanoid.generate(),
11 | name: name,
12 | avatar: avatar,
13 | created_at: DateTime.utc_now(),
14 | updated_at: DateTime.utc_now()
15 | }
16 |
17 | UserStore.add_user(user)
18 |
19 | conn
20 | |> put_status(:created)
21 | |> render("create.json",
22 | user: user,
23 | token:
24 | Phoenix.Token.sign(
25 | Application.get_env(:pictionary, Pictionary)[:secret_key],
26 | Application.get_env(:pictionary, Pictionary)[:salt],
27 | user.id
28 | )
29 | )
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/lib/pictionary_web/controllers/auth/current_use.ex:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb.CurrentUser do
2 | use PictionaryWeb, :controller
3 |
4 | def init(opts), do: opts
5 |
6 | def call(conn, _opts) do
7 | conn
8 | |> current_resource()
9 | |> case do
10 | {:ok, user_id} ->
11 | user = Pictionary.Stores.UserStore.get_user(user_id)
12 | assign(conn, :current_user, user)
13 |
14 | {:error, _} ->
15 | assign(conn, :current_user, nil)
16 | end
17 | end
18 |
19 | defp current_resource(conn) do
20 | token =
21 | conn
22 | |> get_req_header("authorization")
23 | |> get_token()
24 | |> String.replace_leading("Bearer ", "")
25 |
26 | Phoenix.Token.verify(
27 | Application.get_env(:pictionary, Pictionary)[:secret_key],
28 | Application.get_env(:pictionary, Pictionary)[:salt],
29 | token,
30 | max_age: :infinity
31 | )
32 | end
33 |
34 | defp get_token([token]), do: token
35 | defp get_token([]), do: ""
36 | end
37 |
--------------------------------------------------------------------------------
/pictionary-app/src/reducers/userInfo.js:
--------------------------------------------------------------------------------
1 | import generateName from 'sillyname';
2 | import { CHANGE_AVATAR, CHANGE_NAME, SAVE_TOKEN, LOAD_SESSION } from '../constants/actionTypes';
3 | import { getRandomAvatarStyles } from '../helpers/helpers';
4 |
5 | const initialState = { id: null, avatar: getRandomAvatarStyles(), name: generateName().split(' ')[0], token: window.localStorage.getItem('token') || null };
6 | const userInfoReducer = (state = initialState, action) => {
7 | switch (action.type) {
8 | case CHANGE_NAME:
9 | return { ...state, name: action.payload };
10 | case CHANGE_AVATAR:
11 | return { ...state, avatar: { ...state.avatar, ...action.payload } };
12 | case SAVE_TOKEN:
13 | return { ...state, token: action.payload.token, id: action.payload.user.id };
14 | case LOAD_SESSION:
15 | return { ...state, id: action.payload.id, name: action.payload.name, avatar: action.payload.avatar };
16 | default:
17 | return state;
18 | }
19 | };
20 |
21 | export default userInfoReducer;
22 |
--------------------------------------------------------------------------------
/pictionary-app/src/sagas/gameRootSaga.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | /* eslint-disable import/prefer-default-export */
3 | import { takeLatest, throttle } from 'redux-saga/effects';
4 | import {
5 | HANDLE_START_GAME,
6 | HANDLE_GAME_STARTED,
7 | HANDLE_CANVAS_UPDATE,
8 | HANDLE_SEND_MESSAGE,
9 | HANDLE_UPDATE_SELECTED_WORD,
10 | HANDLE_NEW_DRAWER
11 | } from '../constants/actionTypes';
12 | import {
13 | startGame,
14 | handleGameStarted,
15 | updateCanvas,
16 | handleSendMessage,
17 | handleWordSelected,
18 | handleNewDrawer
19 | } from './handlers/gameHandlers';
20 |
21 | export default function* watchsocketSagas() {
22 | yield takeLatest(HANDLE_START_GAME, startGame);
23 | yield takeLatest(HANDLE_GAME_STARTED, handleGameStarted);
24 | yield throttle(10, HANDLE_CANVAS_UPDATE, updateCanvas);
25 | yield takeLatest(HANDLE_SEND_MESSAGE, handleSendMessage);
26 | yield takeLatest(HANDLE_UPDATE_SELECTED_WORD, handleWordSelected);
27 | yield takeLatest(HANDLE_NEW_DRAWER, handleNewDrawer);
28 | }
29 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where 3rd-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Ignore package tarball (built via "mix hex.build").
23 | pictionary-*.tar
24 |
25 | # If NPM crashes, it generates a log, let's ignore it too.
26 | npm-debug.log
27 |
28 | # The directory NPM downloads your dependencies sources to.
29 | /assets/node_modules/
30 |
31 | # Since we are building assets from assets/,
32 | # we ignore priv/static. You may want to comment
33 | # this depending on your deployment strategy.
34 | /priv/static/
35 |
--------------------------------------------------------------------------------
/pictionary-app/src/hooks/usePrevious.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 |
3 | /*
4 |
5 | Implementation taken from:
6 | https://blog.logrocket.com/how-to-get-previous-props-state-with-react-hooks/
7 |
8 | The ref object will always return the same value held in ref.current, to change the value we have to explicitly set it.
9 |
10 | IMPORTANT:
11 | useEffect is only called after the component is rendered with the previous value.
12 | In other words useEffect Hook is always triggered after the return statement of the parent component is evaluated
13 |
14 | Only after the render is done is the ref object updated within useEffect.
15 | This means the returned "ref.current" value always contains the previous value since
16 | the useEffect call updating the ref only executes after the component from which the "usePrevious" hook was called has finished rendering.
17 | */
18 |
19 | const usePrevious = (value) => {
20 | const ref = useRef();
21 | useEffect(() => { ref.current = value; });
22 | return ref.current;
23 | };
24 |
25 | export default usePrevious;
26 |
--------------------------------------------------------------------------------
/test/support/channel_case.ex:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb.ChannelCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | channel tests.
5 |
6 | Such tests rely on `Phoenix.ChannelTest` and also
7 | import other functionality to make it easier
8 | to build common data structures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | we enable the SQL sandbox, so changes done to the database
12 | are reverted at the end of every test. If you are using
13 | PostgreSQL, you can even run database tests asynchronously
14 | by setting `use PictionaryWeb.ChannelCase, async: true`, although
15 | this option is not recommended for other databases.
16 | """
17 |
18 | use ExUnit.CaseTemplate
19 |
20 | using do
21 | quote do
22 | # Import conveniences for testing with channels
23 | import Phoenix.ChannelTest
24 | import PictionaryWeb.ChannelCase
25 |
26 | # The default endpoint for testing
27 | @endpoint PictionaryWeb.Endpoint
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/lib/pictionary_web/controllers/users_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb.UserController do
2 | use PictionaryWeb, :controller
3 | alias Pictionary.{Stores.UserStore, User}
4 |
5 | def update(%{assigns: %{current_user: %User{id: id}}} = conn, user_params) do
6 | id
7 | |> UserStore.get_user()
8 | |> case do
9 | %User{name: existing_name, avatar: existing_avatar} = user ->
10 | user =
11 | %User{
12 | user
13 | | name: user_params["name"] || existing_name,
14 | avatar: user_params["avatar"] || existing_avatar,
15 | updated_at: DateTime.utc_now()
16 | }
17 | |> UserStore.add_user()
18 |
19 | render(conn, "show.json", user: user)
20 |
21 | nil ->
22 | conn
23 | |> Plug.Conn.put_status(:not_found)
24 | |> json(%{message: "User not found !"})
25 | end
26 | end
27 |
28 | def show(%{assigns: %{current_user: %User{id: id}}} = conn, _params) do
29 | render(conn, "show.json", user: UserStore.get_user(id))
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/UserAvatar/userAvatar.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useSelector, useDispatch } from 'react-redux';
3 | import useSetRandomAvatar from '../../hooks/useDidMount';
4 | import Avatar from '../Avatar/Avatar';
5 | import { CHANGE_AVATAR } from '../../constants/actionTypes';
6 | import { getRandomAvatarStyles } from '../../helpers/helpers';
7 |
8 | const UserAvatar = ({ random }) => {
9 | const avatarStyles = useSelector(state => state.userInfo.avatar);
10 | const dispatch = useDispatch();
11 |
12 | /*
13 | Custom hook "useDidMount" is used to set a random avatar to player if dependency "random" changes
14 | The main usage of this custom hook ensures that random avatar is not set due to dependency "random" if its the first render
15 | */
16 | useSetRandomAvatar(() => {
17 | dispatch({ type: CHANGE_AVATAR, payload: getRandomAvatarStyles() });
18 | }, [dispatch, random]);
19 |
20 | return (
21 |
24 | );
25 | };
26 |
27 | export default UserAvatar;
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) [year] [fullname]
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.
--------------------------------------------------------------------------------
/pictionary-app/src/components/HomeHeader/HomeHeader.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useState, useEffect } from 'react';
3 | import { Grid } from '@material-ui/core';
4 | import Avatar from '../Avatar/Avatar';
5 | import { getRandomAvatarStyles, range } from '../../helpers/helpers';
6 | import './HomeHeader.scoped.scss';
7 |
8 | const HomeHeader = () => {
9 | const [, setRandom] = useState(Math.random());
10 | useEffect(() => {
11 | const timer = setInterval(() => setRandom(Math.random), 5000);
12 | return () => clearInterval(timer);
13 | }, []);
14 |
15 | return (
16 |
17 |
18 |
19 |
20 | Pictionary
21 |
22 |
23 | {range(1, 6).map(key => (
24 |
25 | ))}
26 |
27 |
28 |
29 |
30 | );
31 | };
32 |
33 | export default HomeHeader;
34 |
--------------------------------------------------------------------------------
/test/support/conn_case.ex:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb.ConnCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | tests that require setting up a connection.
5 |
6 | Such tests rely on `Phoenix.ConnTest` and also
7 | import other functionality to make it easier
8 | to build common data structures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | we enable the SQL sandbox, so changes done to the database
12 | are reverted at the end of every test. If you are using
13 | PostgreSQL, you can even run database tests asynchronously
14 | by setting `use PictionaryWeb.ConnCase, async: true`, although
15 | this option is not recommended for other databases.
16 | """
17 |
18 | use ExUnit.CaseTemplate
19 |
20 | using do
21 | quote do
22 | # Import conveniences for testing with connections
23 | import Plug.Conn
24 | import Phoenix.ConnTest
25 | import PictionaryWeb.ConnCase
26 |
27 | alias PictionaryWeb.Router.Helpers, as: Routes
28 |
29 | # The default endpoint for testing
30 | @endpoint PictionaryWeb.Endpoint
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/GameNewRoundDialog/GameNewRoundDialog.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { useSelector, useDispatch } from 'react-redux';
3 | import { Dialog, DialogTitle } from '@material-ui/core';
4 | import { HIDE_ROUND_CHANGE_DIALOG } from '../../constants/actionTypes';
5 |
6 | const GameNewRoundDialog = () => {
7 | const [active, currentRound] = useSelector(state => [state.gamePlay.roundChangeDialog, state.gamePlay.currentRound]);
8 | const dispatch = useDispatch();
9 |
10 | useEffect(() => {
11 | let dialogTimer;
12 | if (active) {
13 | dialogTimer = setTimeout(() => {
14 | dispatch({ type: HIDE_ROUND_CHANGE_DIALOG });
15 | }, 2000);
16 | }
17 | return () => dialogTimer && clearTimeout(dialogTimer);
18 | }, [active]);
19 |
20 | return active ? (
21 |
27 |
28 | {`Round ${currentRound}`}
29 |
30 |
31 | ) : null;
32 | };
33 |
34 | export default GameNewRoundDialog;
35 |
--------------------------------------------------------------------------------
/lib/pictionary_web/router.ex:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb.Router do
2 | use PictionaryWeb, :router
3 |
4 | # pipeline :browser do
5 | # plug :accepts, ["html"]
6 | # plug :fetch_session
7 | # plug :fetch_flash
8 | # plug :protect_from_forgery
9 | # plug :put_secure_browser_headers
10 | # end
11 |
12 | pipeline :current_user do
13 | plug PictionaryWeb.CurrentUser
14 | end
15 |
16 | pipeline :token_auth do
17 | plug PictionaryWeb.CurrentUser
18 | plug PictionaryWeb.TokenAuth
19 | end
20 |
21 | pipeline :api do
22 | plug :accepts, ["json"]
23 | end
24 |
25 | # Health check
26 | scope "/", PictionaryWeb do
27 | pipe_through :api
28 |
29 | get "/health", PageController, :health
30 | end
31 |
32 | scope "/api", PictionaryWeb do
33 | pipe_through [:api, :current_user]
34 |
35 | post "/sessions", SessionController, :create
36 | end
37 |
38 | scope "/api", PictionaryWeb do
39 | pipe_through [:api, :token_auth]
40 |
41 | patch "/users", UserController, :update
42 | get "/users", UserController, :show
43 | post "/games", GamesController, :create
44 | get "/games/:game_id", GamesController, :show
45 | end
46 | end
47 |
--------------------------------------------------------------------------------
/lib/pictionary/application.ex:
--------------------------------------------------------------------------------
1 | defmodule Pictionary.Application do
2 | # See https://hexdocs.pm/elixir/Application.html
3 | # for more information on OTP Applications
4 | @moduledoc false
5 |
6 | use Application
7 |
8 | def start(_type, _args) do
9 | children = [
10 | # Start the Telemetry supervisor
11 | PictionaryWeb.Telemetry,
12 | # Start the PubSub system
13 | {Phoenix.PubSub, name: Pictionary.PubSub},
14 | PictionaryWeb.Presence,
15 | Pictionary.StoreSupervisor,
16 | Pictionary.GameChannelWatcher,
17 | Pictionary.GameSupervisor,
18 | Pictionary.DBCleaner,
19 | # Start the Endpoint (http/https)
20 | PictionaryWeb.Endpoint
21 | ]
22 |
23 | # See https://hexdocs.pm/elixir/Supervisor.html
24 | # for other strategies and supported options
25 | opts = [strategy: :one_for_one, name: Pictionary.Supervisor]
26 | Supervisor.start_link(children, opts)
27 | end
28 |
29 | # Tell Phoenix to update the endpoint configuration
30 | # whenever the application is updated.
31 | def config_change(changed, _new, removed) do
32 | PictionaryWeb.Endpoint.config_change(changed, removed)
33 | :ok
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/pictionary-app/src/hocs/withPlayerCountChangeSfx.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { useSelector } from 'react-redux';
3 | import useAudio from '../hooks/useAudio';
4 | import usePrevious from '../hooks/usePrevious';
5 | import playerEnterSfx from '../sounds/player_enter.mp3';
6 | import playerLeaveSfx from '../sounds/player_leave.mp3';
7 |
8 | const withPlayerCountChangeSfx = WrappedComponent => (props) => {
9 | const [soundEnabled, playersLength] = useSelector(state => [state.settings.sound, state.game.players.length]);
10 | const playPlayerEnterSfx = useAudio(playerEnterSfx);
11 | const playPlayerLeaverSfx = useAudio(playerLeaveSfx);
12 |
13 | // Custom hook to store previous player count to detect new player join or leave
14 | const previousUsersCount = usePrevious(playersLength);
15 | useEffect(() => {
16 | if (previousUsersCount < playersLength) playPlayerEnterSfx();
17 | if (previousUsersCount > playersLength) playPlayerLeaverSfx();
18 | }, [playersLength, soundEnabled]);
19 |
20 | return (
21 | // eslint-disable-next-line react/jsx-props-no-spreading
22 |
23 | );
24 | };
25 |
26 | export default withPlayerCountChangeSfx;
27 |
--------------------------------------------------------------------------------
/lib/pictionary_web/views/game_view.ex:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb.GamesView do
2 | use PictionaryWeb, :view
3 |
4 | def render("show.json", %{game: game}) do
5 | %{
6 | id: game.id,
7 | rounds: game.rounds,
8 | time: game.time,
9 | max_players: game.max_players,
10 | custom_words: game.custom_words |> Enum.join(", "),
11 | custom_words_probability: game.custom_words_probability,
12 | public_game: game.public_game,
13 | vote_kick_enabled: game.vote_kick_enabled,
14 | players: game |> get_game_players() |> render_many(PictionaryWeb.UserView, "show.json"),
15 | started: game.started,
16 | creator_id: game.creator_id
17 | }
18 | end
19 |
20 | def render("show_limited.json", %{game: game}) do
21 | %{
22 | id: game.id,
23 | rounds: game.rounds,
24 | time: game.time,
25 | max_players: game.max_players,
26 | public_game: game.public_game,
27 | vote_kick_enabled: game.vote_kick_enabled,
28 | started: game.started,
29 | creator_id: game.creator_id
30 | }
31 | end
32 |
33 | defp get_game_players(game) do
34 | game.players
35 | |> MapSet.to_list()
36 | |> Pictionary.Stores.UserStore.get_users()
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/pictionary-app/src/protected_route.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/jsx-props-no-spreading */
2 | import React from 'react';
3 | import { Route, Redirect, useRouteMatch } from 'react-router-dom';
4 | import { useSelector, useDispatch } from 'react-redux';
5 | import { SAVE_GAME_TO_JOIN_ID } from './constants/actionTypes';
6 |
7 | export default ({ component: Component, ...rest }) => {
8 | const [token, gameId] = useSelector(state => [state.userInfo.token, state.game.id]);
9 | const dispatch = useDispatch();
10 | const lobbyParams = useRouteMatch('/lobby/:game_id');
11 | const gameParams = useRouteMatch('/game/:game_id');
12 | const gameToJoinId = lobbyParams?.params?.game_id || gameParams?.params?.game_id;
13 |
14 | // This dispatch leads to a warning "Cannot update a component while rendering a different component"
15 | // We don't need to save the game to join id when entering a game from lobby, thus we are using "gameId" here
16 | if (!gameId && gameToJoinId) dispatch({ type: SAVE_GAME_TO_JOIN_ID, payload: gameToJoinId });
17 |
18 | return (
19 | (token ? : )}
22 | />
23 | );
24 | };
25 |
--------------------------------------------------------------------------------
/pictionary-app/src/constants/websocketEvents.js:
--------------------------------------------------------------------------------
1 | // == Events received ==
2 |
3 | export const WS_GAME_SETTINGS_UPDATED = 'game_settings_updated';
4 | export const WS_PLAYER_REMOVED = 'player_removed';
5 | export const WS_GAME_ADMIN_UPDATED = 'game_admin_updated';
6 | export const WS_GAME_STARTED = 'game_started';
7 | export const WS_CANVAS_UPDATED = 'canvas_updated';
8 | export const WS_NEW_MESSAGE = 'new_message';
9 | export const WS_NEW_ROUND = 'new_round';
10 | export const WS_SELECTED_WORD = 'selected_word';
11 | export const WS_NEW_DRAWER_WORDS = 'new_drawer_words';
12 | export const WS_GAME_OVER = 'game_over';
13 | export const WS_SCORE_UPDATE = 'score_update';
14 | export const WS_WORD_WAS = 'word_was';
15 | export const WS_VOTE_KICK_UPDATE = 'vote_kick_update';
16 | export const WS_KICK_PLAYER = 'kick_player';
17 | export const WS_GAME_STATS_UPDATED = 'game_stats_update';
18 |
19 | // == Events pushed ==
20 | export const WS_UPDATE_GAME = 'update_game';
21 | export const WS_UPDATE_ADMIN = 'update_admin';
22 | export const WS_START_GAME = 'start_game';
23 | export const WS_CANVAS_UPDATE = 'canvas_update';
24 | export const WS_SEND_MESSAGE = 'send_message';
25 | export const WS_SELECT_WORD = 'select_word';
26 | export const WS_VOTE_TO_KICK = 'vote_to_kick';
27 |
--------------------------------------------------------------------------------
/pictionary-app/src/stores/configureStore.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware, compose } from 'redux';
2 | import createSagaMiddleware from 'redux-saga';
3 | import logger from 'redux-logger';
4 | import { routerMiddleware } from 'connected-react-router';
5 | import { createBrowserHistory } from 'history';
6 | import { watcherSaga } from '../sagas/homeRootSaga';
7 | import LobbyRootSaga from '../sagas/lobbyRootSaga';
8 | import GameRootSaga from '../sagas/gameRootSaga';
9 | import createRootReducer from '../reducers/reducers';
10 |
11 | export const history = createBrowserHistory();
12 |
13 | const devMode = process.env.NODE_ENV === 'development';
14 | const sagaMiddleware = createSagaMiddleware();
15 | const wsMiddleware = createSagaMiddleware();
16 | const middlewares = [sagaMiddleware, wsMiddleware, routerMiddleware(history)];
17 |
18 | if (devMode) middlewares.push(logger);
19 |
20 | // compose: https://stackoverflow.com/questions/41357897/understanding-compose-functions-in-redux
21 | export default () => {
22 | const store = createStore(createRootReducer(history), compose(applyMiddleware(...middlewares)));
23 | sagaMiddleware.run(watcherSaga);
24 | wsMiddleware.run(LobbyRootSaga);
25 | wsMiddleware.run(GameRootSaga);
26 | return store;
27 | };
28 |
--------------------------------------------------------------------------------
/pictionary-app/src/App.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { Route, Switch } from 'react-router-dom';
3 | import { useDispatch } from 'react-redux';
4 | import ProtectedRoute from './protected_route';
5 | import { HANDLE_RESTORE_SESSION } from './constants/actionTypes';
6 | import Home from './pages/Home/Home';
7 | import Lobby from './pages/Lobby/Lobby';
8 | import Game from './pages/Game/Game';
9 | import GamesList from './pages/GamesList/GamesList';
10 | import NotFound from './pages/NotFound/NotFound';
11 | import Layout from './layout/layout';
12 | import './App.scoped.scss';
13 |
14 | function App() {
15 | const dispatch = useDispatch();
16 |
17 | // Load user data if token in present
18 | useEffect(() => dispatch({ type: HANDLE_RESTORE_SESSION }), []);
19 |
20 | return (
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | );
31 | }
32 |
33 | export default App;
34 |
--------------------------------------------------------------------------------
/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your application
2 | # and its dependencies with the aid of the Mix.Config module.
3 | #
4 | # This configuration file is loaded before any dependency and
5 | # is restricted to this project.
6 |
7 | # General application configuration
8 | import Config
9 |
10 | # Configures the endpoint
11 | config :pictionary, PictionaryWeb.Endpoint,
12 | url: [host: "localhost"],
13 | secret_key_base: "hWeSS7q9x9LY66qdQLQXnjjm2DtvqA3r+q1Dq3jC+CgZOaO/DyTp50FEUHbhZD8L",
14 | render_errors: [view: PictionaryWeb.ErrorView, accepts: ~w(html json), layout: false],
15 | pubsub_server: Pictionary.PubSub,
16 | live_view: [signing_salt: "Y912JC8S"]
17 |
18 | # Configures Elixir's Logger
19 | config :logger, :console,
20 | format: "$time $metadata[$level] $message\n",
21 | metadata: [:request_id]
22 |
23 | # Use Jason for JSON parsing in Phoenix
24 | config :phoenix, :json_library, Jason
25 |
26 | config :pictionary, Pictionary,
27 | secret_key: "mWrx5hBSfF+Gz2d7C1QSF5l+kH/4ZI1Jyn3rNurCkzDL72lfnmtwcTbxcdi2+szo",
28 | salt: "4gzgoc7oQu8yOgwoMNaNCikNsreWvMW5"
29 |
30 | # Import environment specific config. This must remain at the bottom
31 | # of this file so it overrides the configuration defined above.
32 | import_config "#{Mix.env()}.exs"
33 |
--------------------------------------------------------------------------------
/lib/pictionary_web/templates/page/index.html.eex:
--------------------------------------------------------------------------------
1 |
2 | <%= gettext "Welcome to %{name}!", name: "Phoenix" %>
3 | Peace of mind from prototype to production
4 |
5 |
6 |
7 |
8 | Resources
9 |
20 |
21 |
22 | Help
23 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/pictionary-app/src/misc/errorBoundary.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-shadow */
2 | /* eslint-disable react/button-has-type */
3 | /* eslint-disable jsx-a11y/img-redundant-alt */
4 | /* eslint-disable react/destructuring-assignment */
5 | import React from 'react';
6 | import * as Sentry from '@sentry/react';
7 | import error from '../images/500.png';
8 |
9 | export default class ErrorBoundary extends React.Component {
10 | constructor(props) {
11 | super(props);
12 | this.state = { hasError: false };
13 | }
14 |
15 | componentDidCatch(error, errorInfo) {
16 | // eslint-disable-next-line no-console
17 | console.log(error, errorInfo);
18 | this.setState({ hasError: true });
19 | // Log error to sentry
20 | Sentry.captureException(error);
21 | }
22 |
23 | render() {
24 | if (this.state.hasError) {
25 | return (
26 |
32 | );
33 | }
34 |
35 | return this.props.children;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/lib/pictionary_web/templates/layout/app.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Pictionary · Phoenix Framework
8 | "/>
9 |
10 |
11 |
12 |
25 |
26 | <%= get_flash(@conn, :info) %>
27 | <%= get_flash(@conn, :error) %>
28 | <%= @inner_content %>
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/pictionary-app/src/cursors/disabled.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/LobbyPlayersList/LobbyPlayersList.scoped.scss:
--------------------------------------------------------------------------------
1 | .playerListContainer {
2 | height: 635px;
3 | overflow: auto;
4 | }
5 |
6 | #playerListHeader {
7 | text-align: center;
8 | font-weight: bolder;
9 | font-size: 1.5rem;
10 | border-bottom: 1px solid grey;
11 | padding: 1.3rem;
12 | font-family: Arial, Helvetica, sans-serif;
13 | }
14 |
15 | .adminHint {
16 | padding-top: 10px;
17 | font-size: .7rem;
18 | color: grey;
19 | font-weight: bold;
20 | font-style: oblique;
21 | }
22 |
23 | #playersList {
24 | display: flex;
25 | flex-flow: row wrap;
26 | justify-content: center;
27 | }
28 |
29 | #playersList > .MuiListItem-root {
30 | width: 8rem !important;
31 | }
32 |
33 | .playerName,
34 | .playerAdmin,
35 | .playerSelf {
36 | max-width: 5rem;
37 | overflow: hidden;
38 | text-align: center;
39 | font-size: 0.7rem;
40 | font-family: Arial, Helvetica, sans-serif;
41 | }
42 |
43 | .playerAdmin {
44 | color: red;
45 | }
46 |
47 | .playerSelf {
48 | color: green;
49 | font-weight: bolder;
50 | }
51 |
52 | .playerData,
53 | .darkPlayerData {
54 | transition: transform 0.2s;
55 | }
56 |
57 | .playerData:hover {
58 | transform: scale(1.1);
59 | background-color: beige;
60 | }
61 |
62 | .darkPlayerData:hover {
63 | transform: scale(1.1);
64 | background-color: hsl(50, 33%, 25%);
65 | }
66 |
--------------------------------------------------------------------------------
/lib/pictionary_web/channels/user_socket.ex:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb.UserSocket do
2 | use Phoenix.Socket
3 |
4 | ## Channels
5 | channel "game:*", PictionaryWeb.GameChannel
6 | channel "game_stats", PictionaryWeb.GameListChannel
7 |
8 | @impl true
9 | def connect(%{"token" => token}, socket, _connect_info) do
10 | Phoenix.Token.verify(
11 | Application.get_env(:pictionary, Pictionary)[:secret_key],
12 | Application.get_env(:pictionary, Pictionary)[:salt],
13 | token,
14 | max_age: :infinity
15 | )
16 | |> case do
17 | {:ok, user_id} ->
18 | user = Pictionary.Stores.UserStore.get_user(user_id)
19 | if user, do: {:ok, assign(socket, :current_user, user)}, else: :error
20 |
21 | _ ->
22 | :error
23 | end
24 | end
25 |
26 | @impl true
27 | def connect(_params, _socket, _connect_info), do: :error
28 |
29 | # Socket id's are topics that allow you to identify all sockets for a given user:
30 | #
31 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}"
32 | #
33 | # Would allow you to broadcast a "disconnect" event and terminate
34 | # all active sockets and channels for a given user:
35 | #
36 | # PictionaryWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
37 | #
38 | # Returning `nil` makes this socket anonymous.
39 | @impl true
40 | def id(_socket), do: nil
41 | end
42 |
--------------------------------------------------------------------------------
/pictionary-app/src/pages/NotFound/NotFound.scoped.scss:
--------------------------------------------------------------------------------
1 | .mainbox {
2 | background-color: #95c2de;
3 | margin: auto;
4 | height: 600px;
5 | width: 600px;
6 | position: relative;
7 | .err {
8 | color: #ffffff;
9 | font-family: "Nunito Sans", sans-serif;
10 | font-size: 11rem;
11 | position: absolute;
12 | left: 20%;
13 | top: 8%;
14 | }
15 |
16 | .far {
17 | position: absolute;
18 | font-size: 8.5rem;
19 | left: 42%;
20 | top: 15%;
21 | color: #ffffff;
22 | }
23 |
24 | .err2 {
25 | color: #ffffff;
26 | font-family: "Nunito Sans", sans-serif;
27 | font-size: 11rem;
28 | position: absolute;
29 | left: 68%;
30 | top: 8%;
31 | }
32 |
33 | .msg {
34 | text-align: center;
35 | font-family: "Nunito Sans", sans-serif;
36 | font-size: 1.6rem;
37 | position: absolute;
38 | left: 16%;
39 | top: 45%;
40 | width: 75%;
41 | }
42 |
43 | a {
44 | text-decoration: none;
45 | color: white;
46 | }
47 |
48 | a:hover {
49 | text-decoration: underline;
50 | }
51 |
52 | .icn-spinner {
53 | animation: spin-animation 3s infinite;
54 | animation-timing-function: linear;
55 | display: inline-block;
56 | }
57 |
58 | @keyframes spin-animation {
59 | 0% {
60 | transform: rotate(0deg);
61 | }
62 | 100% {
63 | transform: rotate(360deg);
64 | }
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/pictionary-app/src/sagas/homeRootSaga.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/prefer-default-export */
2 | import { takeLatest } from 'redux-saga/effects';
3 | import {
4 | HANDLE_CREATE_USER_SESSION,
5 | HANDLE_RESTORE_SESSION,
6 | HANDLE_CREATE_GAME_SESSION,
7 | HANDLE_CREATE_AND_ENTER_GAME_SESSION,
8 | HANDLE_JOIN_EXISTING_GAME_SESSION,
9 | HANDLE_GET_GAME_DATA
10 | } from '../constants/actionTypes';
11 | import {
12 | saveUserSession,
13 | loadUserSession,
14 | createGameSession,
15 | creatAndEnterGameSession,
16 | joinGameSession,
17 | getGameData
18 | } from './handlers/homeHandlers';
19 |
20 | /*
21 | The watcher Saga is a generator thats runs in the background and
22 | listens for any actions dispatched on the store.
23 | It will map them to handler functions that make API requests and store data in redux store
24 | */
25 | export function* watcherSaga() {
26 | // Sagas to send api request and save to store
27 | yield takeLatest(HANDLE_CREATE_USER_SESSION, saveUserSession);
28 | yield takeLatest(HANDLE_CREATE_GAME_SESSION, createGameSession);
29 | yield takeLatest(HANDLE_GET_GAME_DATA, getGameData);
30 |
31 | // Sagas for flow
32 | yield takeLatest(HANDLE_CREATE_AND_ENTER_GAME_SESSION, creatAndEnterGameSession);
33 | yield takeLatest(HANDLE_JOIN_EXISTING_GAME_SESSION, joinGameSession);
34 |
35 | // Sagas for other stuff
36 | yield takeLatest(HANDLE_RESTORE_SESSION, loadUserSession);
37 | }
38 |
--------------------------------------------------------------------------------
/test/support/data_case.ex:
--------------------------------------------------------------------------------
1 | defmodule Pictionary.DataCase do
2 | @moduledoc """
3 | This module defines the setup for tests requiring
4 | access to the application's data layer.
5 |
6 | You may define functions here to be used as helpers in
7 | your tests.
8 |
9 | Finally, if the test case interacts with the database,
10 | we enable the SQL sandbox, so changes done to the database
11 | are reverted at the end of every test. If you are using
12 | PostgreSQL, you can even run database tests asynchronously
13 | by setting `use Pictionary.DataCase, async: true`, although
14 | this option is not recommended for other databases.
15 | """
16 |
17 | use ExUnit.CaseTemplate
18 |
19 | using do
20 | quote do
21 | import Pictionary.DataCase
22 | end
23 | end
24 |
25 | @doc """
26 | A helper that transforms changeset errors into a map of messages.
27 |
28 | assert {:error, changeset} = Accounts.create_user(%{password: "short"})
29 | assert "password is too short" in errors_on(changeset).password
30 | assert %{password: ["password is too short"]} = errors_on(changeset)
31 |
32 | """
33 | def errors_on(changeset) do
34 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
35 | Regex.replace(~r"%{(\w+)}", message, fn _, key ->
36 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
37 | end)
38 | end)
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/GameChat/GameChat.scoped.scss:
--------------------------------------------------------------------------------
1 | .chatContainer {
2 | display: flex;
3 | flex-flow: column;
4 | height: 100%;
5 | max-height: 32rem;
6 | overflow-y: auto;
7 | }
8 |
9 | .chatListContainer {
10 | overflow-y: auto;
11 | overflow-x: hidden;
12 | scroll-behavior: smooth;
13 | padding: 0 15px 0 15px;
14 | }
15 |
16 | .chatItemContainer {
17 | width: 100%;
18 | border-radius: 10px;
19 | background-color: lightgray;
20 | }
21 |
22 | .chat_correct_guess {
23 | background-color: lightgreen;
24 | }
25 |
26 | .chat_too_close_guess {
27 | background-color: #fff44f;
28 | }
29 |
30 | .darkMode {
31 | .chatItemContainer {
32 | background-color: #181818;
33 | }
34 | .chat_correct_guess {
35 | background-color: #228b22;
36 | }
37 |
38 | .chat_too_close_guess {
39 | background-color: #6a5acd;
40 | }
41 | }
42 |
43 | .chatItemName {
44 | font-size: 0.6em;
45 | font-weight: normal;
46 | width: 14rem;
47 | overflow-x: hidden;
48 | text-overflow: ellipsis;
49 | padding: 5px;
50 | }
51 |
52 | .chatItemContent {
53 | font-family: "Times New Roman", Times, serif;
54 | padding: 5px;
55 | font-size: 0.8em;
56 | word-wrap: break-word;
57 | }
58 |
59 | .chatInput > input {
60 | width: 100%;
61 | display: inline-block;
62 | border: 1px solid #ccc;
63 | border-radius: 4px;
64 | box-sizing: border-box;
65 | padding: 12px 20px;
66 | margin: 8px 0;
67 | }
68 |
--------------------------------------------------------------------------------
/pictionary-app/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { Provider } from 'react-redux';
4 | import { ConnectedRouter } from 'connected-react-router';
5 | import * as Sentry from '@sentry/react';
6 | import { Integrations } from '@sentry/tracing';
7 | import configureStore, { history } from './stores/configureStore';
8 | import App from './App';
9 | import './fonts/Laffayette_Comic_Pro.ttf';
10 | import './index.css';
11 | import reportWebVitals from './reportWebVitals';
12 |
13 | Sentry.init({
14 | dsn: 'https://aee184e6915545e895cf2c3a55084104@o833217.ingest.sentry.io/5812987',
15 | integrations: [new Integrations.BrowserTracing()],
16 |
17 | // Set tracesSampleRate to 1.0 to capture 100%
18 | // of transactions for performance monitoring.
19 | // We recommend adjusting this value in production
20 | tracesSampleRate: 1.0
21 | });
22 | const store = configureStore();
23 |
24 | ReactDOM.render(
25 |
26 |
27 |
28 |
29 |
30 |
31 | ,
32 | document.getElementById('root')
33 | );
34 |
35 | // If you want to start measuring performance in your app, pass a function
36 | // to log results (for example: reportWebVitals(console.log))
37 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
38 | reportWebVitals();
39 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/GameToolbar/GameToolbar.scoped.scss:
--------------------------------------------------------------------------------
1 | .color-picker-item {
2 | padding: 5px;
3 | }
4 |
5 | .default-colors {
6 | background-color: lightgray;
7 | }
8 |
9 | .darkMode .default-colors {
10 | background-color: none;
11 | }
12 |
13 | .custom-color-picker {
14 | height: 45px;
15 | border: 0;
16 | }
17 |
18 | .color-picker-item img {
19 | width: 40px;
20 | }
21 |
22 | .hoverable:hover {
23 | transform: scale(1.1);
24 | cursor: pointer;
25 | background-color: lightgray;
26 | border-radius: 5px;
27 | }
28 |
29 | .strokeContainer {
30 | height: 50px;
31 | width: 30px;
32 | padding: 0 10px 0 10px;
33 | border-radius: 5px;
34 | position: relative;
35 | top: 6%;
36 | border: 2px solid white;
37 | }
38 |
39 | .selected {
40 | background-color: lightgray;
41 | border-radius: 5px;
42 | }
43 |
44 | .strokeItem {
45 | margin: auto;
46 | background-color: black;
47 | padding: 0;
48 | border: 2px solid black;
49 | border-radius: 50%;
50 | position: relative;
51 | }
52 |
53 | .strokeContainer:hover {
54 | transform: scale(1.1);
55 | cursor: pointer;
56 | }
57 |
58 | .stroke-3 {
59 | width: 5px;
60 | height: 5px;
61 | top: 40%;
62 | }
63 |
64 | .stroke-4 {
65 | width: 10px;
66 | height: 10px;
67 | top: 35%;
68 | }
69 |
70 | .stroke-5 {
71 | width: 15px;
72 | height: 15px;
73 | top: 30%;
74 | }
75 |
76 | .stroke-6 {
77 | width: 20px;
78 | height: 20px;
79 | top: 25%;
80 | }
81 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # This file excludes paths from the Docker build context.
2 | #
3 | # By default, Docker's build context includes all files (and folders) in the
4 | # current directory. Even if a file isn't copied into the container it is still sent to
5 | # the Docker daemon.
6 | #
7 | # There are multiple reasons to exclude files from the build context:
8 | #
9 | # 1. Prevent nested folders from being copied into the container (ex: exclude
10 | # /assets/node_modules when copying /assets)
11 | # 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc)
12 | # 3. Avoid sending files containing sensitive information
13 | #
14 | # More information on using .dockerignore is available here:
15 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file
16 |
17 | .dockerignore
18 |
19 | # Ignore git, but keep git HEAD and refs to access current commit hash if needed:
20 | #
21 | # $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat
22 | # d0b8727759e1e0e7aa3d41707d12376e373d5ecc
23 | .git
24 | !.git/HEAD
25 | !.git/refs
26 |
27 | # Common development/test artifacts
28 | /cover/
29 | /doc/
30 | /test/
31 | /tmp/
32 | .elixir_ls
33 |
34 | # Mix artifacts
35 | /_build/
36 | /deps/
37 | *.ez
38 |
39 | # Generated on crash by the VM
40 | erl_crash.dump
41 |
42 | # Static artifacts - These should be fetched and built inside the Docker image
43 | /assets/node_modules/
44 | /priv/static/assets/
45 | /priv/static/cache_manifest.json
46 |
--------------------------------------------------------------------------------
/pictionary-app/src/sagas/lobbyRootSaga.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | /* eslint-disable import/prefer-default-export */
3 | import { takeLatest, throttle } from 'redux-saga/effects';
4 | import {
5 | HANDLE_INIT_SOCKET,
6 | HANDLE_INIT_GAME_CHANNEL,
7 | HANDLE_UPDATE_GAME,
8 | HANDLE_UPDATE_ADMIN,
9 | HANDLE_KICK_PLAYER,
10 | HANDLE_PLAYER_KICKED,
11 | HANDLE_ADMIN_UPDATED
12 | } from '../constants/actionTypes';
13 | import {
14 | initWebsocket,
15 | initGameChannel,
16 | updateGameSettings,
17 | updateGameSession,
18 | updateGameAdmin,
19 | removeGamePlayer,
20 | handlePlayerRemove,
21 | handleAdminUpdated
22 | } from './handlers/lobbyHandlers';
23 |
24 | export default function* watchsocketSagas() {
25 | yield takeLatest(HANDLE_INIT_SOCKET, initWebsocket);
26 | yield takeLatest(HANDLE_INIT_GAME_CHANNEL, initGameChannel);
27 |
28 | // Update game settings instantaniously in store
29 | yield takeLatest(HANDLE_UPDATE_GAME, updateGameSettings);
30 |
31 | // HANDLE_UPDATE_GAME is also caught by below yield
32 | // Throttle websocket calls to update game settings in server
33 | // Take latest HANDLE_UPDATE_GAME action in every 500ms window
34 | yield throttle(500, HANDLE_UPDATE_GAME, updateGameSession);
35 |
36 | yield takeLatest(HANDLE_UPDATE_ADMIN, updateGameAdmin);
37 | yield takeLatest(HANDLE_ADMIN_UPDATED, handleAdminUpdated);
38 |
39 | yield takeLatest(HANDLE_KICK_PLAYER, removeGamePlayer);
40 | yield takeLatest(HANDLE_PLAYER_KICKED, handlePlayerRemove);
41 | }
42 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/GameHeaderClock/GameHeaderClock.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { useSelector } from 'react-redux';
3 | import useAudio from '../../hooks/useAudio';
4 | import clockTickSfx from '../../sounds/clock_tick.mp3';
5 | import './GameHeaderClock.scoped.scss';
6 |
7 | const GameHeaderClock = ({ elapsedTime }) => {
8 | const [soundEnabled, drawerId, currentWord, drawTime] = useSelector(state => [
9 | state.settings.sound,
10 | state.gamePlay.drawerId,
11 | state.gamePlay.currentWord,
12 | state.game.time - (elapsedTime || 0)
13 | ]);
14 |
15 | const [timer, setTimer] = useState(drawTime);
16 | const playClockTick = useAudio(clockTickSfx);
17 |
18 | useEffect(() => {
19 | // At every 1 sec set state and decrement timer
20 | const interval = setInterval(() => setTimer((time) => {
21 | // Start tick sound when 7 sec remaining and word was not guessed
22 | if (time === 7 && drawerId) playClockTick();
23 |
24 | if (time <= 0) {
25 | clearInterval(interval);
26 | return 0;
27 | }
28 | return time - 1;
29 | }), 1000);
30 | return () => clearInterval(interval);
31 | }, [drawerId, soundEnabled]);
32 |
33 | let clockColor = 'green';
34 | if (drawTime / 3 > timer) { clockColor = 'red'; } else if (drawTime / 2 > timer) { clockColor = 'yellow'; }
35 |
36 | return (
37 |
38 | { drawerId && currentWord && (
39 |
40 | {timer}
41 |
42 | )}
43 |
44 | );
45 | };
46 |
47 | export default GameHeaderClock;
48 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/LobbyGameSettings/lobbyGameSettings.scoped.scss:
--------------------------------------------------------------------------------
1 | #lobbyHeader {
2 | text-align: center;
3 | font-weight: bolder;
4 | font-size: 1.5rem;
5 | border-bottom: 1px solid grey;
6 | padding: 1.3rem;
7 | font-family: Arial, Helvetica, sans-serif;
8 | }
9 |
10 | .lobbyFormContainer {
11 | padding: 15px;
12 |
13 | .customLabel {
14 | font-size: 0.9rem;
15 | font-family: Arial, Helvetica, sans-serif;
16 | font-weight: bolder;
17 | padding-bottom: 10px;
18 | }
19 |
20 | .customWordSlider {
21 | width: 95% !important;
22 |
23 | .MuiSlider-track {
24 | height: 7px;
25 | border-radius: 7px;
26 | }
27 |
28 | .MuiSlider-thumb {
29 | margin-top: -3px;
30 | }
31 |
32 | .MuiSlider-rail {
33 | height: 6px;
34 | }
35 |
36 | .MuiSlider-mark {
37 | height: 6px;
38 | }
39 | }
40 |
41 | .max-game-time {
42 | padding-top: 10px;
43 | padding-bottom: 10px;
44 | font-family: "Comic Sans MS", "Comic Sans", cursive;
45 | font-weight: bolder;
46 | font-style: italic;
47 | }
48 | }
49 |
50 | .copyLinkContainer {
51 | display: flex;
52 | justify-content: flex-end;
53 | padding: 0 10px 10px 0;
54 | }
55 |
56 | .copiedLinkContainer {
57 | .MuiChip-root {
58 | background-color: green;
59 | }
60 | }
61 |
62 | .startGameButton {
63 | display: flex;
64 | justify-content: center;
65 | }
66 |
67 | .activeStartGameButton {
68 | .MuiButton-contained,
69 | .MuiButton-contained:hover {
70 | background-color: green;
71 | font-weight: bolder;
72 | color: honeydew;
73 | }
74 |
75 | .MuiButton-contained:hover {
76 | background-color: indigo;
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/pictionary-app/src/reducers/game.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable camelcase */
2 | import { SAVE_GAME, UPDATE_GAME_STATE, UPDATE_GAME_PLAYERS, ADMIN_UPDATED, REMOVE_PLAYER } from '../constants/actionTypes';
3 |
4 | const initialState = {
5 | id: null,
6 | rounds: 3,
7 | time: 80,
8 | max_players: 10,
9 | custom_words: '',
10 | custom_words_probability: 50,
11 | public_game: true,
12 | vote_kick_enabled: true,
13 | players: [],
14 | started: false,
15 | creator_id: null
16 | };
17 |
18 | const gameReducer = (state = initialState, action) => {
19 | switch (action.type) {
20 | case SAVE_GAME:
21 | const {
22 | id,
23 | rounds,
24 | time,
25 | max_players,
26 | custom_words,
27 | custom_words_probability,
28 | public_game,
29 | vote_kick_enabled,
30 | players,
31 | started,
32 | creator_id
33 | } = action.payload;
34 | return {
35 | ...state,
36 | id,
37 | rounds,
38 | time,
39 | max_players,
40 | custom_words,
41 | custom_words_probability,
42 | public_game,
43 | vote_kick_enabled,
44 | players,
45 | started,
46 | creator_id
47 | };
48 | case UPDATE_GAME_STATE:
49 | return { ...state, ...action.payload };
50 |
51 | case UPDATE_GAME_PLAYERS:
52 | return { ...state, players: action.payload };
53 |
54 | case ADMIN_UPDATED:
55 | return { ...state, creator_id: action.payload };
56 |
57 | case REMOVE_PLAYER:
58 | return { ...state, players: state.players.filter(player => player.id !== action.payload) };
59 |
60 | default:
61 | return state;
62 | }
63 | };
64 |
65 | export default gameReducer;
66 |
--------------------------------------------------------------------------------
/pictionary-app/src/reducers/settings.js:
--------------------------------------------------------------------------------
1 | import {
2 | TOGGLE_SOUND,
3 | TOGGLE_DARK_MODE,
4 | ADD_ALERT,
5 | CLEAR_ALERT,
6 | SET_LOADING,
7 | CLEAR_LOADING,
8 | SAVE_SOCKET_OBJECT,
9 | SAVE_GAME_CHANNEL,
10 | SAVE_GAME_TO_JOIN_ID,
11 | CLEAR_SOCKET
12 | } from '../constants/actionTypes';
13 |
14 | const initialState = {
15 | sound: window.localStorage.getItem('userSound') !== 'false',
16 | darkMode: window.localStorage.getItem('userTheme') === 'true',
17 | alert: { alertType: null, msg: null },
18 | loading: false,
19 | socket: null,
20 | gameChannel: null,
21 | gameToJoinId: null
22 | };
23 |
24 | const userInfoReducer = (state = initialState, action) => {
25 | switch (action.type) {
26 | case TOGGLE_SOUND:
27 | return { ...state, sound: !state.sound };
28 | case TOGGLE_DARK_MODE:
29 | return { ...state, darkMode: !state.darkMode };
30 | case ADD_ALERT:
31 | return { ...state, alert: { alertType: action.alertType, msg: action.msg } };
32 | case CLEAR_ALERT:
33 | return { ...state, alert: { alertType: null, msg: null } };
34 | case SET_LOADING:
35 | return { ...state, loading: true };
36 | case CLEAR_LOADING:
37 | return { ...state, loading: false };
38 | case SAVE_SOCKET_OBJECT:
39 | return { ...state, socket: action.payload };
40 | case SAVE_GAME_CHANNEL:
41 | return { ...state, gameChannel: action.payload };
42 | case SAVE_GAME_TO_JOIN_ID:
43 | return { ...state, gameToJoinId: action.payload };
44 | case CLEAR_SOCKET:
45 | if (state.socket) state.socket.disconnect();
46 | return { ...state, socket: null, gameChannel: null };
47 | default:
48 | return state;
49 | }
50 | };
51 |
52 | export default userInfoReducer;
53 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/LobbyPlayerDialog/LobbyPlayerDialog.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useDispatch } from 'react-redux';
3 | import { Button, Dialog, DialogContent, DialogTitle } from '@material-ui/core';
4 | import DialogActions from '@material-ui/core/DialogActions';
5 | import { GiWalkingBoot, GiChessKing } from 'react-icons/gi';
6 | import { AiFillCloseCircle } from 'react-icons/ai';
7 | import { HANDLE_UPDATE_ADMIN, HANDLE_KICK_PLAYER } from '../../constants/actionTypes';
8 | import './lobbyPlayerDialog.scoped.scss';
9 |
10 | export default ({ open, player, closeDialog }) => {
11 | const dispatch = useDispatch();
12 |
13 | return (
14 |
15 |
16 |
17 | Player Actions
18 |
19 | }
23 | onClick={() => {
24 | dispatch({ type: HANDLE_KICK_PLAYER, payload: player?.id });
25 | closeDialog();
26 | }}
27 | >
28 | {`Kick ${player?.name}!`}
29 |
30 | }
34 | onClick={() => {
35 | dispatch({ type: HANDLE_UPDATE_ADMIN, payload: player?.id });
36 | closeDialog();
37 | }}
38 | >
39 | Make admin!
40 |
41 |
42 |
43 |
44 | );
45 | };
46 |
--------------------------------------------------------------------------------
/lib/pictionary_web/views/error_helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb.ErrorHelpers do
2 | @moduledoc """
3 | Conveniences for translating and building error messages.
4 | """
5 |
6 | use Phoenix.HTML
7 |
8 | @doc """
9 | Generates tag for inlined form input errors.
10 | """
11 | def error_tag(form, field) do
12 | Enum.map(Keyword.get_values(form.errors, field), fn error ->
13 | content_tag(:span, translate_error(error),
14 | class: "invalid-feedback",
15 | phx_feedback_for: input_name(form, field)
16 | )
17 | end)
18 | end
19 |
20 | @doc """
21 | Translates an error message using gettext.
22 | """
23 | def translate_error({msg, opts}) do
24 | # When using gettext, we typically pass the strings we want
25 | # to translate as a static argument:
26 | #
27 | # # Translate "is invalid" in the "errors" domain
28 | # dgettext("errors", "is invalid")
29 | #
30 | # # Translate the number of files with plural rules
31 | # dngettext("errors", "1 file", "%{count} files", count)
32 | #
33 | # Because the error messages we show in our forms and APIs
34 | # are defined inside Ecto, we need to translate them dynamically.
35 | # This requires us to call the Gettext module passing our gettext
36 | # backend as first argument.
37 | #
38 | # Note we use the "errors" domain, which means translations
39 | # should be written to the errors.po file. The :count option is
40 | # set by Ecto and indicates we should also apply plural rules.
41 | if count = opts[:count] do
42 | Gettext.dngettext(PictionaryWeb.Gettext, "errors", msg, msg, count, opts)
43 | else
44 | Gettext.dgettext(PictionaryWeb.Gettext, "errors", msg, opts)
45 | end
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/pictionary-app/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
16 |
17 |
26 | Pictionary
27 |
28 |
29 | You need to enable JavaScript to run this app.
30 |
31 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/AvatarChooser/avatarChooser.scoped.scss:
--------------------------------------------------------------------------------
1 | .avatarChooserWrapper {
2 | min-width: 1000px;
3 | max-height: 100%;
4 | overflow-y: auto;
5 | overflow-x: hidden;
6 | }
7 |
8 | .avatarChooserHeader {
9 | font-weight: bold;
10 | font-size: 2em;
11 | text-align: center;
12 | background-image: linear-gradient(45deg, #b721ff, #21d4fd);
13 | -webkit-background-clip: text;
14 | -webkit-text-fill-color: transparent;
15 | -moz-background-clip: text;
16 | -moz-text-fill-color: transparent;
17 | }
18 |
19 | .closeButton {
20 | font-size: larger;
21 | margin-top: 10px;
22 | margin-right: 10px;
23 | text-align: end;
24 | }
25 |
26 | .closeButton:hover {
27 | cursor: pointer;
28 | color: red;
29 | }
30 |
31 | .avatarChooserFormItem {
32 | background-color: darksalmon;
33 | }
34 |
35 | .darkMode {
36 | .avatarChooserFormItem,
37 | .MuiInput-input {
38 | color: black;
39 | }
40 | }
41 |
42 | .avatarChooserFormItem {
43 | background-color: darksalmon;
44 | .MuiFormControlLabel-label {
45 | min-width: 170px;
46 | }
47 | }
48 |
49 | @media only screen and (max-device-width: 480px) {
50 | .avatarChooserHeader {
51 | font-size: larger;
52 | }
53 |
54 | .avatarChooserWrapper {
55 | min-width: 100px;
56 | max-width: 350px;
57 | max-height: 100%;
58 | overflow: auto;
59 | }
60 |
61 | .avatarChooserFormItem {
62 | max-width: 150px;
63 |
64 | .MuiFormControlLabel-label {
65 | min-width: 20px;
66 | width: 60px;
67 | font-size: 0.8em;
68 | font-weight: bold;
69 | }
70 |
71 | .MuiSelect-select {
72 | margin: 0;
73 | padding: 0;
74 | text-overflow: clip;
75 | }
76 |
77 | .MuiSelect-icon {
78 | display: none;
79 | }
80 |
81 | .MuiSelect-selectMenu {
82 | max-width: 25px;
83 | font-size: 0.6em !important;
84 | }
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/pictionary-app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "pictionary-app",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@craco/craco": "^6.1.2",
7 | "@material-ui/core": "^4.11.3",
8 | "@sentry/react": "^6.6.0",
9 | "@sentry/tracing": "^6.6.0",
10 | "@testing-library/jest-dom": "^5.11.10",
11 | "@testing-library/react": "^11.2.6",
12 | "@testing-library/user-event": "^12.8.3",
13 | "avataaars": "^1.2.2",
14 | "axios": "^0.21.1",
15 | "connected-react-router": "^6.9.1",
16 | "craco-plugin-scoped-css": "^1.1.1",
17 | "lodash.debounce": "^4.0.8",
18 | "node-sass": "^5.0.0",
19 | "phoenix": "^1.5.8",
20 | "react": "^17.0.2",
21 | "react-color": "^2.19.3",
22 | "react-confetti": "^6.0.1",
23 | "react-dom": "^17.0.2",
24 | "react-icons": "^4.2.0",
25 | "react-loading": "^2.0.3",
26 | "react-redux": "^7.2.3",
27 | "react-router-dom": "^5.2.0",
28 | "react-scripts": "4.0.3",
29 | "react-transition-group": "^4.4.1",
30 | "redux": "^4.0.5",
31 | "redux-logger": "^3.0.6",
32 | "redux-saga": "^1.1.3",
33 | "sillyname": "^0.1.0",
34 | "web-vitals": "^1.1.1"
35 | },
36 | "scripts": {
37 | "start": "craco start",
38 | "build": "craco build && echo '/* /index.html 200' | cat >build/_redirects",
39 | "test": "craco test",
40 | "eject": "react-scripts eject"
41 | },
42 | "eslintConfig": {
43 | "extends": [
44 | "react-app",
45 | "react-app/jest"
46 | ]
47 | },
48 | "browserslist": {
49 | "production": [
50 | ">0.2%",
51 | "not dead",
52 | "not op_mini all"
53 | ],
54 | "development": [
55 | "last 1 chrome version",
56 | "last 1 firefox version",
57 | "last 1 safari version"
58 | ]
59 | },
60 | "devDependencies": {
61 | "eslint": "^7.2.0",
62 | "eslint-config-airbnb": "^18.2.1"
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/lib/pictionary_web/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb.Endpoint do
2 | use Phoenix.Endpoint, otp_app: :pictionary
3 | use Sentry.PlugCapture
4 |
5 | # The session will be stored in the cookie and signed,
6 | # this means its contents can be read but not tampered with.
7 | # Set :encryption_salt if you would also like to encrypt it.
8 | @session_options [
9 | store: :cookie,
10 | key: "_pictionary_key",
11 | signing_salt: "6L91lWqB"
12 | ]
13 |
14 | socket "/socket", PictionaryWeb.UserSocket,
15 | websocket: [timeout: :infinity],
16 | longpoll: false
17 |
18 | # Serve at "/" the static files from "priv/static" directory.
19 | #
20 | # You should set gzip to true if you are running phx.digest
21 | # when deploying your static files in production.
22 | plug Plug.Static,
23 | at: "/",
24 | from: :pictionary,
25 | gzip: false,
26 | only: ~w(css fonts images js favicon.ico robots.txt)
27 |
28 | # Code reloading can be explicitly enabled under the
29 | # :code_reloader configuration of your endpoint.
30 | if code_reloading? do
31 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
32 | plug Phoenix.LiveReloader
33 | plug Phoenix.CodeReloader
34 | end
35 |
36 | plug Plug.RequestId
37 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
38 |
39 | plug Plug.Parsers,
40 | parsers: [:urlencoded, :multipart, :json],
41 | pass: ["*/*"],
42 | json_decoder: Phoenix.json_library()
43 |
44 | plug Sentry.PlugContext
45 | plug Plug.MethodOverride
46 | plug Plug.Head
47 | plug Plug.Session, @session_options
48 |
49 | plug Corsica,
50 | max_age: 600,
51 | origins: [
52 | "http://localhost:3000",
53 | "http://localhost:5000",
54 | "https://pictionary-game.netlify.app"
55 | ],
56 | allow_headers: :all,
57 | allow_methods: :all
58 |
59 | plug PictionaryWeb.Router
60 | end
61 |
--------------------------------------------------------------------------------
/config/dev.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # For development, we disable any cache and enable
4 | # debugging and code reloading.
5 | #
6 | # The watchers configuration can be used to run external
7 | # watchers to your application. For example, we use it
8 | # with webpack to recompile .js and .css sources.
9 | config :pictionary, PictionaryWeb.Endpoint,
10 | http: [port: 4000],
11 | debug_errors: true,
12 | code_reloader: true,
13 | check_origin: false
14 |
15 | # ## SSL Support
16 | #
17 | # In order to use HTTPS in development, a self-signed
18 | # certificate can be generated by running the following
19 | # Mix task:
20 | #
21 | # mix phx.gen.cert
22 | #
23 | # Note that this task requires Erlang/OTP 20 or later.
24 | # Run `mix help phx.gen.cert` for more information.
25 | #
26 | # The `http:` config above can be replaced with:
27 | #
28 | # https: [
29 | # port: 4001,
30 | # cipher_suite: :strong,
31 | # keyfile: "priv/cert/selfsigned_key.pem",
32 | # certfile: "priv/cert/selfsigned.pem"
33 | # ],
34 | #
35 | # If desired, both `http:` and `https:` keys can be
36 | # configured to run both http and https servers on
37 | # different ports.
38 |
39 | # Watch static and templates for browser reloading.
40 | config :pictionary, PictionaryWeb.Endpoint,
41 | live_reload: [
42 | patterns: [
43 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
44 | ~r"priv/gettext/.*(po)$",
45 | ~r"lib/pictionary_web/(live|views)/.*(ex)$",
46 | ~r"lib/pictionary_web/templates/.*(eex)$"
47 | ]
48 | ]
49 |
50 | # Do not include metadata nor timestamps in development logs
51 | config :logger, :console, format: "[$level] $message\n"
52 |
53 | # Set a higher stacktrace during development. Avoid configuring such
54 | # in production as building large stacktraces may be expensive.
55 | config :phoenix, :stacktrace_depth, 20
56 |
57 | # Initialize plugs at runtime for faster development compilation
58 | config :phoenix, :plug_init_mode, :runtime
59 |
--------------------------------------------------------------------------------
/pictionary-app/src/images/undo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 |
8 |
14 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/lib/pictionary_web/telemetry.ex:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb.Telemetry do
2 | use Supervisor
3 | import Telemetry.Metrics
4 |
5 | def start_link(arg) do
6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
7 | end
8 |
9 | @impl true
10 | def init(_arg) do
11 | children = [
12 | # Telemetry poller will execute the given period measurements
13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
15 | # Add reporters as children of your supervision tree.
16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
17 | ]
18 |
19 | Supervisor.init(children, strategy: :one_for_one)
20 | end
21 |
22 | def metrics do
23 | [
24 | # Phoenix Metrics
25 | summary("phoenix.endpoint.stop.duration",
26 | unit: {:native, :millisecond}
27 | ),
28 | summary("phoenix.router_dispatch.stop.duration",
29 | tags: [:route],
30 | unit: {:native, :millisecond}
31 | ),
32 |
33 | # Database Metrics
34 | summary("pictionary.repo.query.total_time", unit: {:native, :millisecond}),
35 | summary("pictionary.repo.query.decode_time", unit: {:native, :millisecond}),
36 | summary("pictionary.repo.query.query_time", unit: {:native, :millisecond}),
37 | summary("pictionary.repo.query.queue_time", unit: {:native, :millisecond}),
38 | summary("pictionary.repo.query.idle_time", unit: {:native, :millisecond}),
39 |
40 | # VM Metrics
41 | summary("vm.memory.total", unit: {:byte, :kilobyte}),
42 | summary("vm.total_run_queue_lengths.total"),
43 | summary("vm.total_run_queue_lengths.cpu"),
44 | summary("vm.total_run_queue_lengths.io")
45 | ]
46 | end
47 |
48 | defp periodic_measurements do
49 | [
50 | # A module, function and arguments to be invoked periodically.
51 | # This function must call :telemetry.execute/3 and a metric must be added above.
52 | # {PictionaryWeb, :count_users, []}
53 | ]
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/pictionary-app/src/layout/layout.scss:
--------------------------------------------------------------------------------
1 | .toggleIcon {
2 | color: black;
3 | }
4 |
5 | .darkMode .toggleIcon {
6 | color: white;
7 | }
8 |
9 | .moonIcon {
10 | color: blue;
11 | }
12 |
13 | .darkMode .sunIcon {
14 | color: orange;
15 | }
16 |
17 | @keyframes animatBackground {
18 | from {
19 | background-position: 0 0;
20 | }
21 | to {
22 | background-position: 100% 0;
23 | }
24 | }
25 |
26 | .bg {
27 | position: fixed;
28 | left: 0;
29 | right: 0;
30 | z-index: 1;
31 | display: block;
32 | z-index: 1;
33 | background-image: url("./../images/main-background.jpg");
34 | background-repeat: repeat;
35 | animation: animatBackground 30s alternate infinite;
36 | height: 500vh;
37 | transition: all 0.5s;
38 | }
39 |
40 | @media only screen and (max-device-width: 480px) {
41 | .bg {
42 | animation: animatBackground 300s alternate infinite;
43 | }
44 | }
45 |
46 | .dark-mode-bg {
47 | background-image: none;
48 | background-color: #ca7968;
49 | background-image: linear-gradient(315deg, #ca7968 0%, #0c0c0c 74%);
50 | background-size: 200% 200%;
51 | animation: gradient 6s ease infinite;
52 | }
53 |
54 | @keyframes gradient {
55 | 0% {
56 | background-position: 0% 50%;
57 | }
58 | 50% {
59 | background-position: 100% 50%;
60 | }
61 | 100% {
62 | background-position: 0% 50%;
63 | }
64 | }
65 |
66 | .main-wrapper-container {
67 | position: fixed;
68 | z-index: 999;
69 | }
70 |
71 | .pictionary-text {
72 | font-size: 1.8em;
73 | //border: 1px solid black;
74 | border-radius: 10px;
75 | // background-color: lightgray;
76 | text-align: center;
77 | // padding-top: 10px;
78 | text-shadow: 0 0 10px azure, 0 0 20px aqua, 0 0 40px dodgerblue, 0 0 80px blue;
79 | color: #000f89;
80 | font-variant: small-caps;
81 | font-weight: bold;
82 | font-family: "Sacramento", cursive;
83 | a {
84 | text-decoration: none;
85 | color: inherit;
86 | }
87 | }
88 |
89 | .darkMode {
90 | .pictionary-text {
91 | text-shadow: 0 0 5px #ffa500, 0 0 15px #ffa500, 0 0 20px #ffa500,
92 | 0 0 40px #ffa500, 0 0 60px #ff0000, 0 0 10px #ff8d00, 0 0 98px #ff0000;
93 | color: #fff6a9;
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/GameWordChoiceDialog/GameWordChoiceDialog.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useSelector, useDispatch } from 'react-redux';
3 | import { Dialog, DialogTitle, DialogActions, Button, makeStyles, withStyles, Slide } from '@material-ui/core';
4 | import { HANDLE_UPDATE_SELECTED_WORD } from '../../constants/actionTypes';
5 |
6 | // eslint-disable-next-line react/jsx-props-no-spreading
7 | const Transition = React.forwardRef((props, ref) => );
8 | const dialogTitle = makeStyles({ root: { textAlign: 'center' } });
9 | const WordButton = withStyles(() => ({
10 | root: {
11 | backgroundColor: 'grey',
12 | color: 'white',
13 | fontWeight: 'bold',
14 | '&:hover': { backgroundColor: 'green' }
15 | }
16 | }))(Button);
17 |
18 | const GameWordChoiceDialog = () => {
19 | const classes = dialogTitle();
20 | const words = useSelector(state => state.gamePlay.words);
21 | const [choosing, chooserName] = useSelector((state) => {
22 | const drawer = state.game.players.find(player => player.id === state.gamePlay.drawerId);
23 | return [state.gamePlay.drawerId === state.userInfo.id, (drawer?.name || 'Anonymous')];
24 | });
25 | const active = useSelector(state => state.gamePlay.words.length !== 0);
26 | const dispatch = useDispatch();
27 |
28 | return active ? (
29 |
36 |
37 | {choosing ? 'Choose a Word' : `${chooserName} is choosing a word...`}
38 |
39 | { choosing
40 | && (
41 |
42 | {words.map(([type, word]) => (
43 | dispatch({ type: HANDLE_UPDATE_SELECTED_WORD, payload: [type, word] })}>
44 | {word}
45 |
46 | ))}
47 |
48 | )}
49 |
50 | ) : null;
51 | };
52 |
53 | export default GameWordChoiceDialog;
54 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Pictionary.MixProject do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :pictionary,
7 | version: "0.1.0",
8 | elixir: "~> 1.7",
9 | elixirc_paths: elixirc_paths(Mix.env()),
10 | compilers: [:phoenix] ++ Mix.compilers(),
11 | start_permanent: Mix.env() == :prod,
12 | aliases: aliases(),
13 | deps: deps()
14 | ]
15 | end
16 |
17 | # Configuration for the OTP application.
18 | #
19 | # Type `mix help compile.app` for more information.
20 | def application do
21 | [
22 | mod: {Pictionary.Application, []},
23 | extra_applications: [:logger, :runtime_tools, :corsica]
24 | ]
25 | end
26 |
27 | # Specifies which paths to compile per environment.
28 | defp elixirc_paths(:test), do: ["lib", "test/support"]
29 | defp elixirc_paths(_), do: ["lib"]
30 |
31 | # Specifies your project dependencies.
32 | #
33 | # Type `mix help deps` for examples and options.
34 | defp deps do
35 | [
36 | {:phoenix, "~> 1.6"},
37 | {:phoenix_html, "~> 3.2"},
38 | {:telemetry_metrics, "~> 0.4"},
39 | {:telemetry_poller, "~> 1.0"},
40 | {:gettext, "~> 0.11"},
41 | {:jason, "~> 1.0"},
42 | {:plug_cowboy, "~> 2.0"},
43 | {:corsica, "~> 1.0"},
44 | {:nanoid, "~> 2.0"},
45 | {:hackney, "~> 1.8"},
46 | {:sentry, "~> 8.0"},
47 |
48 | # dev, test
49 | {:phoenix_live_reload, "~> 1.2", only: :dev},
50 | {:excoveralls, "~> 0.10", only: :test},
51 | # {:mox, "~> 1.0", only: :test},
52 |
53 | # Static code analysis
54 | {:credo, "~> 1.6", only: [:dev, :test], runtime: false},
55 | {:dialyxir, "~> 1.0", only: [:dev], runtime: false}
56 | ]
57 | end
58 |
59 | # Aliases are shortcuts or tasks specific to the current project.
60 | # For example, to install project dependencies and perform other setup tasks, run:
61 | #
62 | # $ mix setup
63 | #
64 | # See the documentation for `Mix` for more info on aliases.
65 | defp aliases do
66 | [
67 | setup: ["deps.get"],
68 | test: ["test"],
69 | quality: ["format", "sobelow --verbose --skip", "dialyzer", "credo --strict"]
70 | ]
71 | end
72 | end
73 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/GamePlayersList/GamePlayersList.scoped.scss:
--------------------------------------------------------------------------------
1 | @mixin vertical-center {
2 | height: 50px;
3 | line-height: 50px;
4 | }
5 |
6 | .gamePlayerListContainer {
7 | overflow-y: auto;
8 | height: 62vh;
9 | }
10 |
11 | .gamePlayerListItem, .gamePlayerListItemGrey {
12 | display: flex;
13 | justify-content: center;
14 | padding: 5px;
15 | border-radius: 5px;
16 | margin: auto;
17 | width: 16rem;
18 | }
19 |
20 | .gamePlayerListContainer .self {
21 | color: green;
22 | }
23 |
24 | .darkMode .gamePlayerListContainer .self {
25 | color: yellow;
26 | }
27 |
28 | .gamePlayerListItemGrey {
29 | background-color: lightgray;
30 | }
31 |
32 | .playerGuessed {
33 | background-color: #D0F0C0;
34 | }
35 |
36 | .darkMode .gamePlayerListItemGrey {
37 | background-color: #181818;
38 | }
39 |
40 | .darkMode {
41 | .playerGuessed {
42 | background-color: #9090ee;
43 | }
44 | }
45 |
46 | .gamePlayerDetails {
47 | padding-left: 1.3rem;
48 | padding-right: 1rem;
49 | }
50 |
51 | .drawIcon {
52 | @include vertical-center;
53 | font-size: 1.5rem;
54 | width: 2.2rem;
55 | animation: shake 4s;
56 | animation-iteration-count: infinite;
57 | }
58 |
59 | .gamePlayerName {
60 | font-size: 0.7em;
61 | font-weight: bold;
62 | text-overflow: ellipsis;
63 | overflow: hidden;
64 | padding: 7px;
65 | text-align: center;
66 | width: 100px;
67 | }
68 |
69 | .gamePlayerScore {
70 | font-size: 0.7em;
71 | font-weight: bold;
72 | text-align: center;
73 | }
74 |
75 | .gamePlayerRank, .gamePlayerAvatar {
76 | @include vertical-center;
77 | }
78 |
79 | @keyframes shake {
80 | 0% { transform: translate(1px, 1px) rotate(0deg); }
81 | 10% { transform: translate(-1px, -2px) rotate(-1deg); }
82 | 20% { transform: translate(-3px, 0px) rotate(1deg); }
83 | 30% { transform: translate(3px, 2px) rotate(0deg); }
84 | 40% { transform: translate(1px, -1px) rotate(1deg); }
85 | 50% { transform: translate(-1px, 2px) rotate(-1deg); }
86 | 60% { transform: translate(-3px, 1px) rotate(0deg); }
87 | 70% { transform: translate(3px, 1px) rotate(-1deg); }
88 | 80% { transform: translate(-1px, -1px) rotate(1deg); }
89 | 90% { transform: translate(1px, 2px) rotate(0deg); }
90 | 100% { transform: translate(1px, 1px) rotate(-1deg); }
91 | }
92 |
--------------------------------------------------------------------------------
/lib/pictionary_web.ex:
--------------------------------------------------------------------------------
1 | defmodule PictionaryWeb do
2 | @moduledoc """
3 | The entrypoint for defining your web interface, such
4 | as controllers, views, channels and so on.
5 |
6 | This can be used in your application as:
7 |
8 | use PictionaryWeb, :controller
9 | use PictionaryWeb, :view
10 |
11 | The definitions below will be executed for every view,
12 | controller, etc, so keep them short and clean, focused
13 | on imports, uses and aliases.
14 |
15 | Do NOT define functions inside the quoted expressions
16 | below. Instead, define any helper function in modules
17 | and import those modules here.
18 | """
19 |
20 | def controller do
21 | quote do
22 | use Phoenix.Controller, namespace: PictionaryWeb
23 |
24 | import Plug.Conn
25 | import PictionaryWeb.Gettext
26 | alias PictionaryWeb.Router.Helpers, as: Routes
27 | end
28 | end
29 |
30 | def view do
31 | quote do
32 | use Phoenix.View,
33 | root: "lib/pictionary_web/templates",
34 | namespace: PictionaryWeb
35 |
36 | # Import convenience functions from controllers
37 | import Phoenix.Controller,
38 | only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1]
39 |
40 | # Include shared imports and aliases for views
41 | unquote(view_helpers())
42 | end
43 | end
44 |
45 | def router do
46 | quote do
47 | use Phoenix.Router
48 |
49 | import Plug.Conn
50 | import Phoenix.Controller
51 | end
52 | end
53 |
54 | def channel do
55 | quote do
56 | use Phoenix.Channel
57 | import PictionaryWeb.Gettext
58 | end
59 | end
60 |
61 | defp view_helpers do
62 | quote do
63 | # Use all HTML functionality (forms, tags, etc)
64 | use Phoenix.HTML
65 |
66 | # Import basic rendering functionality (render, render_layout, etc)
67 | import Phoenix.View
68 |
69 | import PictionaryWeb.ErrorHelpers
70 | import PictionaryWeb.Gettext
71 | alias PictionaryWeb.Router.Helpers, as: Routes
72 | end
73 | end
74 |
75 | @doc """
76 | When used, dispatch to the appropriate controller/view/etc.
77 | """
78 | defmacro __using__(which) when is_atom(which) do
79 | apply(__MODULE__, which, [])
80 | end
81 | end
82 |
--------------------------------------------------------------------------------
/pictionary-app/src/images/pencil.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
7 |
9 |
10 |
11 |
19 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/GameWordBox/GameWordBox.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/no-array-index-key */
2 | import React, { useEffect } from 'react';
3 | import { useSelector, useDispatch } from 'react-redux';
4 | import { REVEAL_MORE_CURRENT_WORD } from '../../constants/actionTypes';
5 | import './GameWordBox.scoped.scss';
6 |
7 | const GameWordBox = ({ elapsedTime, setRevealInterval, clearRevealInterval }) => {
8 | const [
9 | drawTime,
10 | currentWord,
11 | currentWordRevealList
12 | ] = useSelector(state => [
13 | state.game.time,
14 | state.gamePlay.currentWord,
15 | state.gamePlay.currentWordRevealList
16 | ]);
17 | const isDrawer = useSelector(state => state.gamePlay.drawerId === state.userInfo.id);
18 |
19 | const dispatch = useDispatch();
20 |
21 | useEffect(() => {
22 | const intervalPeriod = drawTime / 5;
23 |
24 | if (elapsedTime) {
25 | const lettersToReveal = Math.ceil(elapsedTime / intervalPeriod);
26 | for (let i = 1; i <= lettersToReveal; i += 1) { dispatch({ type: REVEAL_MORE_CURRENT_WORD }); }
27 | }
28 |
29 | const intervalTimer = setInterval(() => {
30 | dispatch({ type: REVEAL_MORE_CURRENT_WORD });
31 | }, intervalPeriod * 1000);
32 |
33 | setRevealInterval(intervalTimer);
34 |
35 | return () => clearRevealInterval();
36 | }, []);
37 |
38 | return (
39 |
40 | {
41 | isDrawer
42 | ? currentWord
43 | .split('')
44 | .map((alphabet, index) => (alphabet === ' '
45 | ?
46 | :
{alphabet}
))
47 | : currentWord.split('')
48 | .map((alphabet, index) => {
49 | let char;
50 | if (currentWordRevealList[index] && alphabet !== '') {
51 | char =
{alphabet}
;
52 | } else if (alphabet === ' ') {
53 | char =
;
54 | } else { char =
; }
55 | return char;
56 | })
57 | }
58 |
59 | );
60 | };
61 |
62 | export default GameWordBox;
63 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/GamePlayersList/GamePlayersList.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-param-reassign */
2 | import React from 'react';
3 | import { useSelector } from 'react-redux';
4 | import { List, ListItem } from '@material-ui/core';
5 | import { FaPencilAlt } from 'react-icons/fa';
6 | import withPlayerCountChangeSfx from '../../hocs/withPlayerCountChangeSfx';
7 | import Avatar from '../Avatar/Avatar';
8 | import './GamePlayersList.scoped.scss';
9 |
10 | const GamePlayersList = () => {
11 | const players = useSelector(state => state.game.players.map((player) => {
12 | player.score = state.gamePlay.scores[player.id] || 0;
13 | player.guessed = !!state.gamePlay.guessers.find(playerId => playerId === player.id);
14 | return player;
15 | }));
16 |
17 | const [drawerId, currentUserId] = useSelector(state => [state.gamePlay.drawerId, state.userInfo.id]);
18 | return (
19 |
20 |
21 | {players.sort((player1, player2) => player2.score - player1.score).map((player, index) => (
22 |
28 |
29 |
{`#${index + 1}`}
30 |
31 |
34 | {`${player.name} ${player.id === currentUserId ? '(You)' : ''}`}
35 |
36 |
{`Points: ${player.score}`}
37 |
38 |
39 | {player.id === drawerId && }
40 |
41 |
44 |
45 |
46 | ))}
47 |
48 |
49 | );
50 | };
51 |
52 | export default withPlayerCountChangeSfx(GamePlayersList);
53 |
--------------------------------------------------------------------------------
/pictionary-app/src/images/eraser.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
7 |
10 |
13 |
16 |
18 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/LobbyPlayersList/LobbyPlayersList.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable camelcase */
2 | import React, { useState } from 'react';
3 | import { useSelector } from 'react-redux';
4 | import { Paper, List, ListItem } from '@material-ui/core';
5 | import Avatar from '../Avatar/Avatar';
6 | import LobbyPlayerDialog from '../LobbyPlayerDialog/LobbyPlayerDialog';
7 | import './LobbyPlayersList.scoped.scss';
8 | import withPlayerCountChangeSfx from '../../hocs/withPlayerCountChangeSfx';
9 |
10 | const LobbyPlayersList = () => {
11 | const [
12 | selfId,
13 | players,
14 | creator_id,
15 | max_players,
16 | darkMode
17 | ] = useSelector(state => [state.userInfo.id, state.game.players, state.game.creator_id, state.game.max_players, state.settings.darkMode]);
18 | const [lobbyPlayerDialog, lobbyPlayerDialogToggle] = useState(false);
19 | const [lobbySelectedPlayer, lobbyChangeSelectedPlayer] = useState(null);
20 | const isAdmin = useSelector(state => state.game.creator_id === state.userInfo.id);
21 |
22 | const handlePlayerClick = (player) => {
23 | lobbyPlayerDialogToggle(true);
24 | lobbyChangeSelectedPlayer(player);
25 | };
26 |
27 | return (
28 |
29 |
33 |
34 | {players.map(player => (
35 | isAdmin && player.id !== creator_id && handlePlayerClick(player)}>
36 |
37 |
40 |
41 | {player.name}
42 |
43 | {player.id === creator_id ?
Admin
: ''}
44 | {player.id === selfId ?
(You)
: ''}
45 |
46 |
47 | ))}
48 |
49 | lobbyPlayerDialogToggle(false)} />
50 |
51 | );
52 | };
53 |
54 | export default withPlayerCountChangeSfx(LobbyPlayersList);
55 |
--------------------------------------------------------------------------------
/pictionary-app/src/images/save.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 |
8 |
9 |
10 |
11 |
12 |
13 |
15 |
16 |
18 |
19 |
21 |
23 |
24 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/lib/pictionary/stores/user_store.ex:
--------------------------------------------------------------------------------
1 | defmodule Pictionary.Stores.UserStore do
2 | use GenServer
3 | alias Pictionary.User
4 |
5 | @table_name :user_table
6 |
7 | require Logger
8 |
9 | # 1 month
10 | @record_expiry 30 * 24 * 60 * 60
11 |
12 | # Public API
13 | def get_user(user_id) do
14 | case GenServer.call(__MODULE__, {:get, user_id}) do
15 | [] -> nil
16 | [{_user_id, user_data}] -> user_data
17 | end
18 | end
19 |
20 | def get_users(user_ids) do
21 | GenServer.call(__MODULE__, {:bulk_get, user_ids})
22 | end
23 |
24 | def add_user(user) do
25 | GenServer.call(__MODULE__, {:set, user})
26 | end
27 |
28 | def remove_old_records do
29 | GenServer.cast(__MODULE__, :remove_old_records)
30 | end
31 |
32 | # GenServer callbacks
33 | def start_link(_opts) do
34 | GenServer.start_link(__MODULE__, nil, name: __MODULE__)
35 | end
36 |
37 | def init(_args) do
38 | # Create a ETS table
39 | # private access ensure read/write limited to owner process.
40 | :ets.new(@table_name, [:named_table, :set, :private])
41 |
42 | {:ok, nil}
43 | end
44 |
45 | def handle_call({:get, user_id}, _from, state) do
46 | user = :ets.lookup(@table_name, user_id)
47 | {:reply, user, state}
48 | end
49 |
50 | def handle_call({:bulk_get, user_ids}, _from, state) do
51 | # Reference: https://elixirforum.com/t/best-way-to-get-multiple-keys-from-static-ets-table/23692/9
52 | users =
53 | :ets.select(@table_name, for(user_id <- user_ids, do: {{user_id, :_}, [], [:"$_"]}))
54 | |> Enum.map(fn {_user_id, user_data} -> user_data end)
55 |
56 | {:reply, users, state}
57 | end
58 |
59 | def handle_call({:set, %User{id: user_id} = user_data}, _from, state) do
60 | # Below pattern match ensure genserver faliure and restart in case
61 | # of ETS insertion faliure
62 | true = :ets.insert(@table_name, {user_id, user_data})
63 | {:reply, user_data, state}
64 | end
65 |
66 | def handle_cast(:remove_old_records, state) do
67 | Logger.info("User Store cleanup start")
68 |
69 | :ets.tab2list(@table_name)
70 | |> Enum.each(fn {_id, user} -> remove_stale_records(user) end)
71 |
72 | Logger.info("User Store cleanup end")
73 | {:noreply, state}
74 | end
75 |
76 | defp remove_stale_records(%User{id: id, created_at: created_at}) do
77 | diff = DateTime.utc_now() |> DateTime.diff(created_at)
78 |
79 | if diff > @record_expiry do
80 | :ets.delete(@table_name, id)
81 | Logger.info("Drop user id #{id}")
82 | end
83 | end
84 | end
85 |
--------------------------------------------------------------------------------
/priv/gettext/en/LC_MESSAGES/errors.po:
--------------------------------------------------------------------------------
1 | ## `msgid`s in this file come from POT (.pot) files.
2 | ##
3 | ## Do not add, change, or remove `msgid`s manually here as
4 | ## they're tied to the ones in the corresponding POT file
5 | ## (with the same domain).
6 | ##
7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge`
8 | ## to merge POT files into PO files.
9 | msgid ""
10 | msgstr ""
11 | "Language: en\n"
12 |
13 | ## From Ecto.Changeset.cast/4
14 | msgid "can't be blank"
15 | msgstr ""
16 |
17 | ## From Ecto.Changeset.unique_constraint/3
18 | msgid "has already been taken"
19 | msgstr ""
20 |
21 | ## From Ecto.Changeset.put_change/3
22 | msgid "is invalid"
23 | msgstr ""
24 |
25 | ## From Ecto.Changeset.validate_acceptance/3
26 | msgid "must be accepted"
27 | msgstr ""
28 |
29 | ## From Ecto.Changeset.validate_format/3
30 | msgid "has invalid format"
31 | msgstr ""
32 |
33 | ## From Ecto.Changeset.validate_subset/3
34 | msgid "has an invalid entry"
35 | msgstr ""
36 |
37 | ## From Ecto.Changeset.validate_exclusion/3
38 | msgid "is reserved"
39 | msgstr ""
40 |
41 | ## From Ecto.Changeset.validate_confirmation/3
42 | msgid "does not match confirmation"
43 | msgstr ""
44 |
45 | ## From Ecto.Changeset.no_assoc_constraint/3
46 | msgid "is still associated with this entry"
47 | msgstr ""
48 |
49 | msgid "are still associated with this entry"
50 | msgstr ""
51 |
52 | ## From Ecto.Changeset.validate_length/3
53 | msgid "should be %{count} character(s)"
54 | msgid_plural "should be %{count} character(s)"
55 | msgstr[0] ""
56 | msgstr[1] ""
57 |
58 | msgid "should have %{count} item(s)"
59 | msgid_plural "should have %{count} item(s)"
60 | msgstr[0] ""
61 | msgstr[1] ""
62 |
63 | msgid "should be at least %{count} character(s)"
64 | msgid_plural "should be at least %{count} character(s)"
65 | msgstr[0] ""
66 | msgstr[1] ""
67 |
68 | msgid "should have at least %{count} item(s)"
69 | msgid_plural "should have at least %{count} item(s)"
70 | msgstr[0] ""
71 | msgstr[1] ""
72 |
73 | msgid "should be at most %{count} character(s)"
74 | msgid_plural "should be at most %{count} character(s)"
75 | msgstr[0] ""
76 | msgstr[1] ""
77 |
78 | msgid "should have at most %{count} item(s)"
79 | msgid_plural "should have at most %{count} item(s)"
80 | msgstr[0] ""
81 | msgstr[1] ""
82 |
83 | ## From Ecto.Changeset.validate_number/3
84 | msgid "must be less than %{number}"
85 | msgstr ""
86 |
87 | msgid "must be greater than %{number}"
88 | msgstr ""
89 |
90 | msgid "must be less than or equal to %{number}"
91 | msgstr ""
92 |
93 | msgid "must be greater than or equal to %{number}"
94 | msgstr ""
95 |
96 | msgid "must be equal to %{number}"
97 | msgstr ""
98 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/GameVoteKickButton/GameVoteKickButton.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/no-unescaped-entities */
2 | /* eslint-disable react/jsx-one-expression-per-line */
3 | import React, { useEffect, useState } from 'react';
4 | import { useSelector, useDispatch } from 'react-redux';
5 | import { push } from 'connected-react-router';
6 | import { Button } from '@material-ui/core';
7 | import { GiHighKick } from 'react-icons/gi';
8 | import { ADD_ALERT } from '../../constants/actionTypes';
9 | import { WS_VOTE_KICK_UPDATE, WS_KICK_PLAYER, WS_VOTE_TO_KICK } from '../../constants/websocketEvents';
10 | import './GameVoteKickButton.scoped.scss';
11 |
12 | const GameVoteKickButton = () => {
13 | const dispatch = useDispatch();
14 | const [selfId, drawerId] = useSelector(state => [state.userInfo.id, state.gamePlay.drawerId]);
15 | const [gameChannel, playersCount, selfVoteKick, drawerName] = useSelector(state => [
16 | state.settings.gameChannel,
17 | state.game.players.length,
18 | state.userInfo.id === state.gamePlay.drawerId,
19 | state.game.players.find(player => player.id === state.gamePlay.drawerId)?.name || 'Anonymous'
20 | ]);
21 | const [count, setVoteKickCount] = useState(0);
22 | const [voted, setVoted] = useState(false);
23 |
24 | useEffect(() => {
25 | gameChannel.on(WS_VOTE_KICK_UPDATE, payload => setVoteKickCount(payload.vote_count));
26 | gameChannel.on(WS_KICK_PLAYER, (payload) => {
27 | if (payload.player_id === selfId) {
28 | setTimeout(() => dispatch({ type: ADD_ALERT, alertType: 'info', msg: 'You were vote kicked from the game!' }), 1000);
29 | dispatch(push('/'));
30 | }
31 | });
32 | return () => {
33 | gameChannel.off(WS_VOTE_KICK_UPDATE);
34 | gameChannel.off(WS_KICK_PLAYER);
35 | };
36 | }, []);
37 |
38 | useEffect(() => {
39 | setVoteKickCount(0);
40 | setVoted(false);
41 | }, [drawerId]);
42 |
43 | const text = count > 0 ? `(${count}/${playersCount - 1})` : '';
44 |
45 | return selfVoteKick || !drawerId || playersCount < 3 ? null : (
46 |
47 | 0 ? 'secondary' : 'primary'}
51 | startIcon={ }
52 | disabled={voted}
53 | onClick={() => {
54 | setVoted(true);
55 | gameChannel.push(WS_VOTE_TO_KICK, {});
56 | }}
57 | style={{ textTransform: 'none' }}
58 | >
59 | {`Vote Kick ${drawerName}${text}`}
60 |
61 |
62 | );
63 | };
64 |
65 | export default GameVoteKickButton;
66 |
--------------------------------------------------------------------------------
/priv/gettext/errors.pot:
--------------------------------------------------------------------------------
1 | ## This is a PO Template file.
2 | ##
3 | ## `msgid`s here are often extracted from source code.
4 | ## Add new translations manually only if they're dynamic
5 | ## translations that can't be statically extracted.
6 | ##
7 | ## Run `mix gettext.extract` to bring this file up to
8 | ## date. Leave `msgstr`s empty as changing them here has no
9 | ## effect: edit them in PO (`.po`) files instead.
10 |
11 | ## From Ecto.Changeset.cast/4
12 | msgid "can't be blank"
13 | msgstr ""
14 |
15 | ## From Ecto.Changeset.unique_constraint/3
16 | msgid "has already been taken"
17 | msgstr ""
18 |
19 | ## From Ecto.Changeset.put_change/3
20 | msgid "is invalid"
21 | msgstr ""
22 |
23 | ## From Ecto.Changeset.validate_acceptance/3
24 | msgid "must be accepted"
25 | msgstr ""
26 |
27 | ## From Ecto.Changeset.validate_format/3
28 | msgid "has invalid format"
29 | msgstr ""
30 |
31 | ## From Ecto.Changeset.validate_subset/3
32 | msgid "has an invalid entry"
33 | msgstr ""
34 |
35 | ## From Ecto.Changeset.validate_exclusion/3
36 | msgid "is reserved"
37 | msgstr ""
38 |
39 | ## From Ecto.Changeset.validate_confirmation/3
40 | msgid "does not match confirmation"
41 | msgstr ""
42 |
43 | ## From Ecto.Changeset.no_assoc_constraint/3
44 | msgid "is still associated with this entry"
45 | msgstr ""
46 |
47 | msgid "are still associated with this entry"
48 | msgstr ""
49 |
50 | ## From Ecto.Changeset.validate_length/3
51 | msgid "should be %{count} character(s)"
52 | msgid_plural "should be %{count} character(s)"
53 | msgstr[0] ""
54 | msgstr[1] ""
55 |
56 | msgid "should have %{count} item(s)"
57 | msgid_plural "should have %{count} item(s)"
58 | msgstr[0] ""
59 | msgstr[1] ""
60 |
61 | msgid "should be at least %{count} character(s)"
62 | msgid_plural "should be at least %{count} character(s)"
63 | msgstr[0] ""
64 | msgstr[1] ""
65 |
66 | msgid "should have at least %{count} item(s)"
67 | msgid_plural "should have at least %{count} item(s)"
68 | msgstr[0] ""
69 | msgstr[1] ""
70 |
71 | msgid "should be at most %{count} character(s)"
72 | msgid_plural "should be at most %{count} character(s)"
73 | msgstr[0] ""
74 | msgstr[1] ""
75 |
76 | msgid "should have at most %{count} item(s)"
77 | msgid_plural "should have at most %{count} item(s)"
78 | msgstr[0] ""
79 | msgstr[1] ""
80 |
81 | ## From Ecto.Changeset.validate_number/3
82 | msgid "must be less than %{number}"
83 | msgstr ""
84 |
85 | msgid "must be greater than %{number}"
86 | msgstr ""
87 |
88 | msgid "must be less than or equal to %{number}"
89 | msgstr ""
90 |
91 | msgid "must be greater than or equal to %{number}"
92 | msgstr ""
93 |
94 | msgid "must be equal to %{number}"
95 | msgstr ""
96 |
--------------------------------------------------------------------------------
/pictionary-app/src/constants/avatarStyles.js:
--------------------------------------------------------------------------------
1 | export default {
2 | top: [
3 | 'NoHair',
4 | 'Eyepatch',
5 | 'Hat',
6 | 'Hijab',
7 | 'Turban',
8 | 'WinterHat1',
9 | 'WinterHat2',
10 | 'WinterHat3',
11 | 'WinterHat4',
12 | 'LongHairBigHair',
13 | 'LongHairBob',
14 | 'LongHairBun',
15 | 'LongHairCurly',
16 | 'LongHairCurvy',
17 | 'LongHairDreads',
18 | 'LongHairFrida',
19 | 'LongHairFro',
20 | 'LongHairFroBand',
21 | 'LongHairNotTooLong',
22 | 'LongHairShavedSides',
23 | 'LongHairMiaWallace',
24 | 'LongHairStraight',
25 | 'LongHairStraight2',
26 | 'LongHairStraightStrand',
27 | 'ShortHairDreads01',
28 | 'ShortHairDreads02',
29 | 'ShortHairFrizzle',
30 | 'ShortHairShaggyMullet',
31 | 'ShortHairShortCurly',
32 | 'ShortHairShortFlat',
33 | 'ShortHairShortRound',
34 | 'ShortHairShortWaved',
35 | 'ShortHairSides',
36 | 'ShortHairTheCaesar',
37 | 'ShortHairTheCaesarSidePart'
38 | ],
39 | accessories: ['Blank', 'Kurt', 'Prescription01', 'Prescription02', 'Round', 'Sunglasses', 'Wayfarers'],
40 | hairColor: ['Auburn', 'Black', 'Blonde', 'BlondeGolden', 'Brown', 'BrownDark', 'PastelPink', 'Platinum', 'Red', 'SilverGray'],
41 | facialHairType: ['Blank', 'BeardMedium', 'BeardLight', 'BeardMajestic', 'MoustacheFancy', 'MoustacheMagnum'],
42 | facialHairColor: ['Auburn', 'Black', 'Blonde', 'BlondeGolden', 'Brown', 'BrownDark', 'Platinum', 'Red'],
43 | clotheType: [
44 | 'BlazerShirt',
45 | 'BlazerSweater',
46 | 'CollarSweater',
47 | 'GraphicShirt',
48 | 'Hoodie',
49 | 'Overall',
50 | 'ShirtCrewNeck',
51 | 'ShirtScoopNeck',
52 | 'ShirtVNeck'
53 | ],
54 | clotheColor: [
55 | 'Black',
56 | 'Blue01',
57 | 'Blue02',
58 | 'Blue03',
59 | 'Gray01',
60 | 'Gray02',
61 | 'Heather',
62 | 'PastelBlue',
63 | 'PastelGreen',
64 | 'PastelOrange',
65 | 'PastelRed',
66 | 'PastelYellow',
67 | 'Pink',
68 | 'Red',
69 | 'White'
70 | ],
71 | eyeType: ['Close', 'Cry', 'Default', 'Dizzy', 'EyeRoll', 'Happy', 'Hearts', 'Side', 'Squint', 'Surprised', 'Wink', 'WinkWacky'],
72 | eyeBrowType: [
73 | 'Angry',
74 | 'AngryNatural',
75 | 'Default',
76 | 'DefaultNatural',
77 | 'FlatNatural',
78 | 'RaisedExcited',
79 | 'RaisedExcitedNatural',
80 | 'SadConcerned',
81 | 'SadConcernedNatural',
82 | 'UnibrowNatural',
83 | 'UpDown',
84 | 'UpDownNatural'
85 | ],
86 | mouth: ['Concerned', 'Default', 'Disbelief', 'Eating', 'Grimace', 'Sad', 'ScreamOpen', 'Serious', 'Smile', 'Tongue', 'Twinkle', 'Vomit'],
87 | skinColor: ['Tanned', 'Yellow', 'Pale', 'Light', 'Brown', 'DarkBrown', 'Black']
88 | };
89 |
--------------------------------------------------------------------------------
/pictionary-app/src/images/delete.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 |
7 |
9 |
11 |
13 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
--------------------------------------------------------------------------------
/pictionary-app/src/images/clock.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 |
7 |
11 |
12 |
13 |
14 |
15 |
18 |
19 |
20 |
21 |
22 |
25 |
26 |
27 |
28 |
29 |
34 |
35 |
36 |
37 |
38 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian instead of
2 | # Alpine to avoid DNS resolution issues in production.
3 | #
4 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu
5 | # https://hub.docker.com/_/ubuntu?tab=tags
6 | #
7 | #
8 | # This file is based on these images:
9 | #
10 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
11 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20220801-slim - for the release image
12 | # - https://pkgs.org/ - resource for finding needed packages
13 | # - Ex: hexpm/elixir:1.13.4-erlang-25.0.4-debian-bullseye-20220801-slim
14 | #
15 | ARG ELIXIR_VERSION=1.13.4
16 | ARG OTP_VERSION=25.0.4
17 | ARG DEBIAN_VERSION=bullseye-20220801-slim
18 |
19 | ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
20 | ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
21 |
22 | FROM ${BUILDER_IMAGE} as builder
23 |
24 | # install build dependencies
25 | RUN apt-get update -y && apt-get install -y build-essential git \
26 | && apt-get clean && rm -f /var/lib/apt/lists/*_*
27 |
28 | # prepare build dir
29 | WORKDIR /app
30 |
31 | # install hex + rebar
32 | RUN mix local.hex --force && \
33 | mix local.rebar --force
34 |
35 | # set build ENV
36 | ENV MIX_ENV="prod"
37 |
38 | # install mix dependencies
39 | COPY mix.exs mix.lock ./
40 | RUN mix deps.get --only $MIX_ENV
41 | RUN mkdir config
42 |
43 | # copy compile-time config files before we compile dependencies
44 | # to ensure any relevant config change will trigger the dependencies
45 | # to be re-compiled.
46 | COPY config/config.exs config/${MIX_ENV}.exs config/
47 | RUN mix deps.compile
48 |
49 | COPY priv priv
50 |
51 | COPY lib lib
52 |
53 | # Compile the release
54 | RUN mix compile
55 |
56 | # Changes to config/runtime.exs don't require recompiling the code
57 | COPY config/runtime.exs config/
58 |
59 | COPY rel rel
60 | RUN mix release
61 |
62 | # start a new build stage so that the final image will only contain
63 | # the compiled release and other runtime necessities
64 | FROM ${RUNNER_IMAGE}
65 |
66 | RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales \
67 | && apt-get clean && rm -f /var/lib/apt/lists/*_*
68 |
69 | # Set the locale
70 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
71 |
72 | ENV LANG en_US.UTF-8
73 | ENV LANGUAGE en_US:en
74 | ENV LC_ALL en_US.UTF-8
75 |
76 | WORKDIR "/app"
77 | RUN chown nobody /app
78 |
79 | # set runner ENV
80 | ENV MIX_ENV="prod"
81 |
82 | # Appended by flyctl
83 | ENV ECTO_IPV6 true
84 | ENV ERL_AFLAGS "-proto_dist inet6_tcp"
85 |
86 | # Only copy the final release from the build stage
87 | COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/pictionary ./
88 |
89 | USER nobody
90 |
91 | CMD ["/app/bin/server"]
92 |
--------------------------------------------------------------------------------
/pictionary-app/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "jest": true
5 | },
6 | "extends": ["airbnb", "plugin:flowtype/recommended"],
7 | "parser": "babel-eslint",
8 | "plugins": ["flowtype"],
9 | "rules": {
10 | "arrow-body-style": "warn",
11 | "arrow-parens": ["warn", "as-needed", { "requireForBlockBody": true }],
12 | "camelcase": "warn",
13 | "comma-dangle": ["warn", "never"],
14 | "consistent-return": "off",
15 | "class-methods-use-this": "off",
16 | "function-paren-newline": "off",
17 | "guard-for-in": "warn",
18 | "import/extensions": "warn",
19 | "import/first": "warn",
20 | "import/no-extraneous-dependencies": "off",
21 | "import/no-duplicates": "off",
22 | "import/no-unresolved": "off",
23 | "import/no-named-as-default": "off",
24 | "jsx-a11y/alt-text": "warn",
25 | "jsx-a11y/click-events-have-key-events": "off",
26 | "jsx-a11y/no-noninteractive-element-interactions": "warn",
27 | "jsx-a11y/anchor-is-valid": "off",
28 | "jsx-a11y/no-static-element-interactions": "warn",
29 | "jsx-a11y/no-noninteractive-tabindex": "warn",
30 | "jsx-a11y/label-has-for": "warn",
31 | "jsx-a11y/mouse-events-have-key-events": "off",
32 | "max-len": ["warn", 160, 2],
33 | "no-else-return": "warn",
34 | "no-multi-assign": "warn",
35 | "no-nested-ternary": "warn",
36 | "no-use-before-define": ["error", "nofunc"],
37 | "no-param-reassign": "warn",
38 | "no-prototype-builtins": "warn",
39 | "no-return-assign": "warn",
40 | "no-restricted-syntax": "warn",
41 | "no-shadow": "warn",
42 | "no-underscore-dangle": "off",
43 | "no-unused-vars": "warn",
44 | "no-var": "warn",
45 | "no-case-declarations": "off",
46 | "one-var": "warn",
47 | "object-curly-newline": ["warn", { "multiline": true }],
48 | "one-var-declaration-per-line": "off",
49 | "object-shorthand": "warn",
50 | "prefer-destructuring": "warn",
51 | "prefer-const": "warn",
52 | "prefer-template": "warn",
53 | "quotes": "warn",
54 | "react/default-props-match-prop-types": "warn",
55 | "react/forbid-prop-types": "off",
56 | "react/jsx-boolean-value": "off",
57 | "react/jsx-filename-extension": "off",
58 | "react/jsx-no-undef": "warn",
59 | "react/jsx-no-duplicate-props": "warn",
60 | "react/jsx-no-bind": "warn",
61 | "react/jsx-tag-spacing": "warn",
62 | "react/no-array-index-key": "warn",
63 | "react/no-find-dom-node": "warn",
64 | "react/no-multi-comp": "off",
65 | "react/no-unused-prop-types": "warn",
66 | "react/no-unused-state": "warn",
67 | "react/no-did-mount-set-state": "off",
68 | "react/jsx-no-target-blank": "warn",
69 | "react/prop-types": "off",
70 | "react/require-default-props": "off",
71 | "react/prefer-stateless-function": "warn",
72 | "react/sort-comp": "warn",
73 | "vars-on-top": "warn"
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/AvatarChooser/AvatarChooser.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useSelector, useDispatch } from 'react-redux';
3 | import { Paper, Grid, MenuItem, Select, FormGroup, FormControlLabel, FormControl, Box } from '@material-ui/core';
4 | import { AiFillCloseCircle } from 'react-icons/ai';
5 | import Avatar from '../Avatar/Avatar';
6 | import AVATAR_STYLES from '../../constants/avatarStyles';
7 | import { CHANGE_AVATAR } from '../../constants/actionTypes';
8 | import { getInputlabel } from '../../helpers/helpers';
9 | import './avatarChooser.scoped.scss';
10 |
11 | const AvatarChooser = ({ closeModal }) => {
12 | const dispatch = useDispatch();
13 | const avatar = useSelector(state => state.userInfo.avatar);
14 | const darkMode = useSelector(state => state.settings.darkMode);
15 |
16 | return (
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | Customise your avatar
26 |
27 |
28 |
29 |
30 | {Object.entries(AVATAR_STYLES).map(([avatarStyle, values]) => {
31 | const label = getInputlabel(avatarStyle);
32 | return (
33 |
34 |
37 | {
40 | avatar[avatarStyle] = event.target.value;
41 | dispatch({
42 | type: CHANGE_AVATAR,
43 | payload: avatar
44 | });
45 | }}
46 | style={{ marginLeft: '20px' }}
47 | >
48 | {values.map((value, index) => (
49 | // eslint-disable-next-line react/no-array-index-key
50 | {getInputlabel(value)}
51 | ))}
52 |
53 |
54 | )}
55 | labelPlacement="start"
56 | label={label}
57 | />
58 |
59 | );
60 | })}
61 |
62 |
63 |
64 |
65 | );
66 | };
67 |
68 | export default AvatarChooser;
69 |
--------------------------------------------------------------------------------
/pictionary-app/src/helpers/helpers.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-bitwise */
2 | import AVATAR_STYLES from '../constants/avatarStyles';
3 |
4 | export const getRandomAvatarStyles = () => {
5 | const getRandomStyle = styles => styles[Math.floor(Math.random() * styles.length)];
6 | const randomisedStyles = {};
7 | // eslint-disable-next-line no-return-assign
8 | Object.keys(AVATAR_STYLES).forEach(key => (randomisedStyles[key] = getRandomStyle(AVATAR_STYLES[key])));
9 | return randomisedStyles;
10 | };
11 |
12 | export const getInputlabel = (input) => {
13 | const label = input.split(/(?=[A-Z])/).join(' ');
14 | return label[0].toUpperCase() + label.slice(1);
15 | };
16 |
17 | export const getTokenFromLocalStorage = () => window.localStorage.getItem('token');
18 |
19 | export const getRandomItem = list => list[Math.floor(Math.random() * list.length)];
20 |
21 | export const clipboardCopy = (text) => {
22 | const el = document.createElement('textarea');
23 | el.value = text;
24 | document.body.appendChild(el);
25 | el.select();
26 | document.execCommand('copy');
27 | document.body.removeChild(el);
28 | };
29 |
30 | export const range = (startAt = 1, size = 10, step = 1) => {
31 | const arr = [];
32 | for (let i = startAt; i <= size; i += step) {
33 | arr.push(i);
34 | }
35 |
36 | return arr;
37 | };
38 |
39 | export const getWinnerPosition = (position) => {
40 | switch (position) {
41 | case 1: return 'Ist';
42 | case 2: return '2nd';
43 | case 3: return '3rd';
44 | default: return '';
45 | }
46 | };
47 |
48 | export const randomIndex = (array) => {
49 | const randomNumber = array.reduce((index, sum) => sum + index) % array.length;
50 | return array[randomNumber];
51 | };
52 |
53 | export const loadCanvasData = (data, callback) => {
54 | const img = new Image();
55 | img.src = data;
56 | img.onload = () => callback(img);
57 | };
58 |
59 | // I am too lazy to write this and don't want to include a heavy library like
60 | // moment js so I copied this from https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site
61 | export const humanizeTime = (seconds) => {
62 | let interval = seconds / 31536000;
63 |
64 | if (interval > 1) {
65 | return `${Math.floor(interval)} years`;
66 | }
67 | interval = seconds / 2592000;
68 | if (interval > 1) {
69 | return `${Math.floor(interval)} months`;
70 | }
71 | interval = seconds / 86400;
72 | if (interval > 1) {
73 | return `${Math.floor(interval)} days`;
74 | }
75 | interval = seconds / 3600;
76 | if (interval > 1) {
77 | return `${Math.floor(interval)} hours`;
78 | }
79 | interval = seconds / 60;
80 | if (interval > 1) {
81 | return `${Math.floor(interval)} minutes`;
82 | }
83 | return `${Math.floor(seconds)} seconds`;
84 | };
85 |
86 | export const timeSince = (date) => {
87 | const seconds = Math.floor((new Date() - new Date(date)) / 1000);
88 | return humanizeTime(seconds);
89 | };
90 |
--------------------------------------------------------------------------------
/pictionary-app/src/sagas/handlers/gameHandlers.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-vars */
2 | /* eslint-disable no-console */
3 | /* eslint-disable import/prefer-default-export */
4 | import { put, select } from 'redux-saga/effects';
5 | import { push } from 'connected-react-router';
6 | import { ADD_ALERT } from '../../constants/actionTypes';
7 | import { WS_START_GAME, WS_CANVAS_UPDATE, WS_SEND_MESSAGE, WS_SELECT_WORD } from '../../constants/websocketEvents';
8 | import newDrawerSfx from '../../sounds/new_drawer.mp3';
9 |
10 | // Handle admin updated
11 | export function* startGame(_action) {
12 | try {
13 | const gameChannel = yield select(state => state.settings.gameChannel);
14 | if (!gameChannel) throw new Error('Game channel not initialized');
15 | gameChannel.push(WS_START_GAME);
16 | } catch (error) {
17 | console.log('Failed to push updates to game data', error);
18 | yield put({ type: ADD_ALERT, alertType: 'error', msg: 'Failed to push updates to game data' });
19 | }
20 | }
21 |
22 | export function* handleGameStarted(_action) {
23 | const gameId = yield select(state => state.game.id);
24 |
25 | yield put(push(`/game/${gameId}`));
26 | }
27 |
28 | export function* updateCanvas(action) {
29 | try {
30 | const gameChannel = yield select(state => state.settings.gameChannel);
31 | if (!gameChannel) throw new Error('Game channel not initialized');
32 | gameChannel.push(WS_CANVAS_UPDATE, { canvas_data: action.payload });
33 | // yield put({ type: UPDATE_CANVAS, payload: action.payload });
34 | } catch (error) {
35 | console.log('Failed to push updates to game data', error);
36 | yield put({ type: ADD_ALERT, alertType: 'error', msg: 'Failed to push updates to game data' });
37 | }
38 | }
39 |
40 | export function* handleSendMessage(action) {
41 | try {
42 | const gameChannel = yield select(state => state.settings.gameChannel);
43 | if (!gameChannel) throw new Error('Game channel not initialized');
44 | gameChannel.push(WS_SEND_MESSAGE, { message: action.payload });
45 | } catch (error) {
46 | console.log('Failed to push updates to game data', error);
47 | yield put({ type: ADD_ALERT, alertType: 'error', msg: 'Failed to push updates to game data' });
48 | }
49 | }
50 |
51 | export function* handleWordSelected(action) {
52 | try {
53 | const gameChannel = yield select(state => state.settings.gameChannel);
54 | if (!gameChannel) throw new Error('Game channel not initialized');
55 | gameChannel.push(WS_SELECT_WORD, { word: action.payload });
56 | } catch (error) {
57 | console.log('Failed to push updates to game data', error);
58 | yield put({ type: ADD_ALERT, alertType: 'error', msg: 'Failed to push updates to game data' });
59 | }
60 | }
61 |
62 | export function* handleNewDrawer() {
63 | const soundEnabled = yield select(state => state.settings.sound);
64 |
65 | // Play the new drawer sfx, its difficult to use the audio audio hook here
66 | if (soundEnabled) new Audio(newDrawerSfx).play();
67 | }
68 |
--------------------------------------------------------------------------------
/config/runtime.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # config/runtime.exs is executed for all environments, including
4 | # during releases. It is executed after compilation and before the
5 | # system starts, so it is typically used to load production configuration
6 | # and secrets from environment variables or elsewhere. Do not define
7 | # any compile-time configuration in here, as it won't be applied.
8 | # The block below contains prod specific runtime configuration.
9 | if config_env() == :prod do
10 | # The secret key base is used to sign/encrypt cookies and other secrets.
11 | # A default value is used in config/dev.exs and config/Pictionary.exs but you
12 | # want to use a different value for prod and you most likely don't want
13 | # to check this value into version control, so we use an environment
14 | # variable instead.
15 | secret_key_base =
16 | System.get_env("SECRET_KEY_BASE") ||
17 | raise """
18 | environment variable SECRET_KEY_BASE is missing.
19 | You can generate one by calling: mix phx.gen.secret
20 | """
21 |
22 | app_name =
23 | System.get_env("FLY_APP_NAME") ||
24 | raise "FLY_APP_NAME not available"
25 |
26 | config :pictionary, PictionaryWeb.Endpoint,
27 | url: [host: "#{app_name}.fly.dev", port: 80],
28 | server: true,
29 | check_origin: ["https://pictionary-game.netlify.app"],
30 | http: [
31 | # Enable IPv6 and bind on all interfaces.
32 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
33 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
34 | # for details about using IPv6 vs IPv4 and loopback vs public addresses.
35 | ip: {0, 0, 0, 0, 0, 0, 0, 0},
36 | port: String.to_integer(System.get_env("PORT") || "4000")
37 | ],
38 | secret_key_base: secret_key_base
39 |
40 | # ## Using releases
41 | #
42 | # If you are doing OTP releases, you need to instruct Phoenix
43 | # to start each relevant endpoint:
44 | #
45 | # config :pictionary, PictionaryWeb.Endpoint, server: true
46 | #
47 | # Then you can assemble a release by calling `mix release`.
48 | # See `mix help release` for more information.
49 |
50 | # ## Configuring the mailer
51 | #
52 | # In production you need to configure the mailer to use a different adapter.
53 | # Also, you may need to configure the Swoosh API client of your choice if you
54 | # are not using SMTP. Here is an example of the configuration:
55 | #
56 | # config :pictionary, Pictionary.Mailer,
57 | # adapter: Swoosh.Adapters.Mailgun,
58 | # api_key: System.get_env("MAILGUN_API_KEY"),
59 | # domain: System.get_env("MAILGUN_DOMAIN")
60 | #
61 | # For this example you need include a HTTP client required by Swoosh API client.
62 | # Swoosh supports Hackney and Finch out of the box:
63 | #
64 | # config :swoosh, :api_client, Swoosh.ApiClient.Hackney
65 | #
66 | # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
67 | end
68 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/GameHeader/GameHeader.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { useSelector, useDispatch } from 'react-redux';
3 | import { Grid } from '@material-ui/core';
4 | import { BsArrowsFullscreen } from 'react-icons/bs';
5 | import GameHeaderClock from '../GameHeaderClock/GameHeaderClock';
6 | import GameWordBox from '../GameWordBox/GameWordBox';
7 | import { RESET_ELAPSED_TIME } from '../../constants/actionTypes';
8 | import SaveSvg from '../../images/save.svg';
9 | import './GameHeader.scoped.scss';
10 |
11 | const GameHeader = ({ canvasRef, setRevealInterval, clearRevealInterval }) => {
12 | const [
13 | gameId,
14 | totalRounds,
15 | currentRound,
16 | currentWord,
17 | elapsedTime
18 | ] = useSelector(state => [state.game.id, state.game.rounds, state.gamePlay.currentRound, state.gamePlay.currentWord, state.gamePlay.elapsedTime]);
19 |
20 | const dispatch = useDispatch();
21 | useEffect(() => dispatch({ type: RESET_ELAPSED_TIME }), []);
22 |
23 | const downloadCanvasImage = () => {
24 | const link = document.createElement('a');
25 | link.download = `pictionary_${gameId}.jpeg`;
26 | link.href = canvasRef.current?.toDataURL();
27 | link.click();
28 | };
29 |
30 | function toggleFullScreen() {
31 | const doc = window.document;
32 | const docEl = doc.documentElement;
33 |
34 | const requestFullScreen = docEl.requestFullscreen || docEl.mozRequestFullScreen || docEl.webkitRequestFullScreen || docEl.msRequestFullscreen;
35 | const cancelFullScreen = doc.exitFullscreen || doc.mozCancelFullScreen || doc.webkitExitFullscreen || doc.msExitFullscreen;
36 |
37 | if (!doc.fullscreenElement && !doc.mozFullScreenElement && !doc.webkitFullscreenElement && !doc.msFullscreenElement) {
38 | requestFullScreen.call(docEl);
39 | } else {
40 | cancelFullScreen.call(doc);
41 | }
42 | }
43 |
44 | return (
45 |
46 |
47 | {currentWord && }
48 |
49 |
50 |
51 | {`Round ${currentRound} of ${totalRounds}`}
52 |
53 |
54 |
55 | {currentWord && }
56 |
57 |
58 |
64 |
65 |
66 |
67 |
68 |
69 | toggleFullScreen()} className="fullScreen" title="Fullscreen" />
70 |
71 |
72 |
73 | );
74 | };
75 |
76 | export default GameHeader;
77 |
--------------------------------------------------------------------------------
/lib/pictionary/game_channel_watcher.ex:
--------------------------------------------------------------------------------
1 | defmodule Pictionary.GameChannelWatcher do
2 | use GenServer
3 | alias Pictionary.Stores.GameStore
4 |
5 | # Phoenix presence does not have any direct way to execute code when someone disconnects
6 | # from channel using terminate callback on channel genserver is not ideal instead the below watcher genserver helps to solve this
7 | # This watcher genserver watches a process subscribed to a channel.
8 | # This is achived by linking(Process.link/1) the subscribed channel process to the genserver process whenever someone joins the channel
9 | # and therefore if channel process disconnect genserver process will get to know about it
10 | # Here I have made this watcher specifically for the game channel but this can be generalized and used for watching multiple channels as given bellow:
11 | # Implementation taken from: https://stackoverflow.com/questions/33934029/how-to-detect-if-a-user-left-a-phoenix-channel-due-to-a-network-disconnect
12 |
13 | ## Client API
14 |
15 | def monitor(pid, data) do
16 | GenServer.call(__MODULE__, {:monitor, pid, data})
17 | end
18 |
19 | def demonitor(pid) do
20 | GenServer.call(__MODULE__, {:demonitor, pid})
21 | end
22 |
23 | ## Server API
24 |
25 | def start_link(_) do
26 | GenServer.start_link(__MODULE__, [], name: __MODULE__)
27 | end
28 |
29 | def init(_) do
30 | # When the linked process dies the caller process meaning this genserver process also dies
31 | # Here we set the flag :trap_exit to true to TRAP such exits in the handle_info({:EXIT...) block
32 | # We avoid this genserver from dieing by trapping exists and then remove the user who left from ets game data
33 | Process.flag(:trap_exit, true)
34 | {:ok, %{}}
35 | end
36 |
37 | def handle_call({:monitor, pid, {game_id, user_id}}, _from, state) do
38 | # Link the channel process with the current process
39 | # When two processes are linked, each one receives exit signals from the other when either of the process dies
40 | Process.link(pid)
41 | Task.start_link(fn -> GameStore.add_player(game_id, user_id) end)
42 | {:reply, :ok, Map.put(state, pid, {game_id, user_id})}
43 | end
44 |
45 | def handle_call({:demonitor, pid}, _from, state) do
46 | case Map.fetch(state, pid) do
47 | :error ->
48 | {:reply, :ok, state}
49 |
50 | {:ok, {game_id, user_id}} ->
51 | Process.unlink(pid)
52 | # Update ets game player list to remove the user which left
53 | Task.start_link(fn -> GameStore.remove_player(game_id, user_id) end)
54 | {:reply, :ok, Map.delete(state, pid)}
55 | end
56 | end
57 |
58 | def handle_info({:EXIT, pid, _reason}, state) do
59 | case Map.fetch(state, pid) do
60 | :error ->
61 | {:noreply, state}
62 |
63 | {:ok, {game_id, user_id}} ->
64 | # Update ets game player list to remove the user which left
65 | Task.start_link(fn -> GameStore.remove_player(game_id, user_id) end)
66 | {:noreply, Map.delete(state, pid)}
67 | end
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/pictionary-app/src/pages/Home/Home.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable max-len */
2 | /* eslint-disable react/jsx-one-expression-per-line */
3 | import React, { useEffect } from 'react';
4 | import { useDispatch } from 'react-redux';
5 | import { Container, Paper, Accordion, AccordionSummary, AccordionDetails, withStyles } from '@material-ui/core';
6 | import HomeHeader from '../../components/HomeHeader/HomeHeader';
7 | import UserInfo from '../../components/UserInfo/userInfo';
8 | import { CLEAR_SOCKET, RESET_GAME_STATE } from '../../constants/actionTypes';
9 | import './home.scoped.scss';
10 |
11 | const StyledAccordionSummary = withStyles({ content: { flexGrow: 0 } })(AccordionSummary);
12 | const StyledAccordionDetails = withStyles({ root: { justifyContent: 'center' } })(AccordionDetails);
13 |
14 | const Home = () => {
15 | const dispatch = useDispatch();
16 |
17 | // Clear and disconnect exisiting socket or channels if any
18 | useEffect(() => {
19 | dispatch({ type: CLEAR_SOCKET });
20 | dispatch({ type: RESET_GAME_STATE });
21 | }, []);
22 |
23 | return (
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | How to play
35 |
36 |
37 |
38 |
Pictionary is free multipleyer drawing and guessing game.
39 | One game consists of a few rounds in which every round someone has to draw their chosen word and others have to guess it to gain points!
40 |
41 | When its your turn to draw, you will have to choose a word and draw that word, alternatively when somebody else is drawing you have to type your guess into the chat to gain points, be quick, the
42 | earlier you guess a word the more points you get!
43 |
44 |
45 | The person with the most points at the end of game will then be crowned as the winner!
46 |
47 |
48 |
49 |
50 |
51 |
52 | About
53 |
54 |
55 |
56 |
Know more about this project here.
57 |
Found a bug or want to request a feature? Create an issue here.
58 |
59 |
60 |
61 |
62 |
63 |
64 | );
65 | };
66 |
67 | export default Home;
68 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/GameOverDialog/GameOverDialog.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { useSelector, useDispatch } from 'react-redux';
3 | import { push } from 'connected-react-router';
4 | import Confetti from 'react-confetti';
5 | import { Grid, Dialog, DialogTitle, DialogContent, Slide, makeStyles } from '@material-ui/core';
6 | import Avatar from '../Avatar/Avatar';
7 | import useAudio from '../../hooks/useAudio';
8 | import winnerSfx from '../../sounds/winner.mp3';
9 | import { getWinnerPosition } from '../../helpers/helpers';
10 | import './GameOverDialog.scoped.scss';
11 |
12 | const contentStyles = makeStyles({ root: { overflow: 'hidden' } });
13 | const dialogStyles = makeStyles(() => ({ paper: { minWidth: '600px', minHeight: '250px' } }));
14 | // eslint-disable-next-line react/jsx-props-no-spreading
15 | const Transition = React.forwardRef((props, ref) => );
16 |
17 | const GameOverDialog = () => {
18 | const players = useSelector(state => state.game.players
19 | .sort((player1, player2) => player2.score - player1.score)
20 | .slice(0, 3).map((player) => {
21 | // eslint-disable-next-line no-param-reassign
22 | player.score = state.gamePlay.scores[player.id] || 0;
23 | return player;
24 | }));
25 | const [soundEnabled, selfId] = useSelector(state => [state.settings.sound, state.userInfo.id]);
26 | const playWinnerSfx = useAudio(winnerSfx);
27 | const dispatch = useDispatch();
28 |
29 | useEffect(() => {
30 | if (players.find(({ id }) => selfId === id)) playWinnerSfx();
31 | const timerRef = setTimeout(() => dispatch(push('/')), 10000);
32 |
33 | return () => clearTimeout(timerRef);
34 | }, [soundEnabled]);
35 |
36 | const dialogContentClasses = contentStyles();
37 | const dialogClasses = dialogStyles();
38 | return (
39 |
47 |
48 | Game Over !
49 |
50 |
51 |
55 |
56 | {
57 | players.map((player, index) => (
58 |
59 |
60 |
{getWinnerPosition(index + 1)}
61 |
62 |
{player.name}
63 |
64 |
65 | ))
66 | }
67 |
68 |
69 |
70 | );
71 | };
72 |
73 | export default GameOverDialog;
74 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | # Pictionary
6 |
7 | [**Pictionary**](https://pictionary-game.netlify.app/) is a free multiplayer drawing and guessing game.
8 |
9 | A game consists of a few rounds in which every round someone has to draw their chosen word and others have to guess it to gain points! Players with the highest scores are the winners at the end of the game.
10 |
11 | [Play now!](https://pictionary-game.netlify.app/)
12 |
13 | ## A Demo of the app
14 | https://user-images.githubusercontent.com/39219943/122636633-e280cc00-d107-11eb-9923-62819cfe8470.mp4
15 |
16 | ## Features
17 |
18 | * Play with your friends in a private room or other users in a public game.
19 | * Tweak the game according to your likings, and your own custom words.
20 | * A refreshing new UI and dark mode support
21 | * If you get disconnected from a game you will not lose your score just rejoin again and continue
22 | * The best part is it's free and there are NO ADS!
23 |
24 | I took inspiration from [skribbl.io](https://skribbl.io/) to make this project, however, I have added a bunch of new stuff in addition to what scribble offers like a new UI, dark mode, ability to preserve scores if disconnected and the best part there are no ads!
25 |
26 | ## Bugs, feature requests, and contributions
27 |
28 | Found a bug or want a new feature?
29 | Please [create an issue](https://github.com/Arp-G/pictionary/issues/new).
30 |
31 | Want to contribute?
32 | * Create a PR and I will be happy to review and merge it
33 | * Start this project
34 | * Tell your friends about the project.
35 |
36 | Like my work? Consider [contributing](https://www.paypal.com/paypalme/arprokz) to this project and support me to improve it.
37 |
38 | ## Setup and run locally
39 |
40 | If you want to run this project locally continue reading
41 |
42 | Few things to know about the technology stack used in the project:
43 | * The application uses [Elixir](https://elixir-lang.org/) for the backend server and [react js](https://reactjs.org/) for the frontend application.
44 | * There is no database, all data is stored in an in-memory database that comes with elixir called [ETS](https://elixirschool.com/en/lessons/specifics/ets/) and will be lost when the backend server is stopped.
45 |
46 | ### Setup using Docker
47 |
48 | Get the app running easily using docker.
49 |
50 | * Install [docker](https://docs.docker.com/engine/install/) and [docker compose](https://docs.docker.com/compose/install/)
51 | * Clone this project
52 | * `cd` into the project directory and run `docker-compose up`
53 |
54 | Thats it! Access the application at http://localhost:3000/
55 |
56 | ### Setup manually
57 |
58 | * Make sure you have [elixir](https://elixir-lang.org/install.html) and [npm](https://www.npmjs.com/get-npm) installed
59 | * Clone this project
60 | * `cd` into the project directory and install dependencies by running `mix deps.get` and finally start the backend server by running `mix phx.server`
61 | * Next cd into the `pictionary-app` directory and install dependencies by running `npm install` and start the front end application by running `npm start`
62 | * Enjoy!
63 |
--------------------------------------------------------------------------------
/pictionary-app/src/helpers/floodFill.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-plusplus */
2 | /* eslint-disable no-bitwise */
3 | // Canvas flood fill, taken from: https://codepen.io/Geeyoam/pen/vLGZzG
4 |
5 | function getColorAtPixel(imageData, x, y) {
6 | const { width, data } = imageData;
7 |
8 | return {
9 | r: data[4 * (width * y + x) + 0],
10 | g: data[4 * (width * y + x) + 1],
11 | b: data[4 * (width * y + x) + 2],
12 | a: data[4 * (width * y + x) + 3]
13 | };
14 | }
15 |
16 | function setColorAtPixel(imageData, color, x, y) {
17 | const { width, data } = imageData;
18 |
19 | data[4 * (width * y + x) + 0] = color.r & 0xff;
20 | data[4 * (width * y + x) + 1] = color.g & 0xff;
21 | data[4 * (width * y + x) + 2] = color.b & 0xff;
22 | data[4 * (width * y + x) + 3] = color.a & 0xff;
23 | }
24 |
25 | function colorMatch(a, b) {
26 | return a.r === b.r && a.g === b.g && a.b === b.b && a.a === b.a;
27 | }
28 |
29 | function floodFill(imageData, newColor, x, y) {
30 | const { width, height } = imageData;
31 | const stack = [];
32 | const baseColor = getColorAtPixel(imageData, x, y);
33 | let operator = { x, y };
34 | // Check if base color and new color are the same
35 | if (colorMatch(baseColor, newColor)) return;
36 |
37 | // Add the clicked location to stack
38 | stack.push({ x: operator.x, y: operator.y });
39 |
40 | while (stack.length) {
41 | operator = stack.pop();
42 | let contiguousDown = true; // Vertical is assumed to be true
43 | let contiguousUp = true; // Vertical is assumed to be true
44 | let contiguousLeft = false;
45 | let contiguousRight = false;
46 |
47 | // Move to top most contiguousDown pixel
48 | while (contiguousUp && operator.y >= 0) {
49 | operator.y--;
50 | contiguousUp = colorMatch(getColorAtPixel(imageData, operator.x, operator.y), baseColor);
51 | }
52 |
53 | // Move downward
54 | while (contiguousDown && operator.y < height) {
55 | setColorAtPixel(imageData, newColor, operator.x, operator.y);
56 |
57 | // Check left
58 | if (operator.x - 1 >= 0 && colorMatch(getColorAtPixel(imageData, operator.x - 1, operator.y), baseColor)) {
59 | if (!contiguousLeft) {
60 | contiguousLeft = true;
61 | stack.push({ x: operator.x - 1, y: operator.y });
62 | }
63 | } else {
64 | contiguousLeft = false;
65 | }
66 |
67 | // Check right
68 | if (operator.x + 1 < width && colorMatch(getColorAtPixel(imageData, operator.x + 1, operator.y), baseColor)) {
69 | if (!contiguousRight) {
70 | stack.push({ x: operator.x + 1, y: operator.y });
71 | contiguousRight = true;
72 | }
73 | } else {
74 | contiguousRight = false;
75 | }
76 |
77 | operator.y++;
78 | contiguousDown = colorMatch(getColorAtPixel(imageData, operator.x, operator.y), baseColor);
79 | }
80 | }
81 | }
82 |
83 | const hexToRGB = (hex) => {
84 | const r = parseInt(hex.slice(1, 3), 16);
85 | const g = parseInt(hex.slice(3, 5), 16);
86 | const b = parseInt(hex.slice(5, 7), 16);
87 |
88 | return { r, g, b, a: 0xff };
89 | };
90 |
91 | export default ({ nativeEvent: { clientX, clientY } }, canvas, ctx, color) => {
92 | const rect = canvas.getBoundingClientRect();
93 | const x = Math.round(clientX - rect.left);
94 | const y = Math.round(clientY - rect.top);
95 | const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
96 | floodFill(imageData, hexToRGB(color), x, y);
97 | ctx.putImageData(imageData, 0, 0);
98 | };
99 |
--------------------------------------------------------------------------------
/pictionary-app/README.md:
--------------------------------------------------------------------------------
1 | # Getting Started with Create React App
2 |
3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
4 |
5 | ## Available Scripts
6 |
7 | In the project directory, you can run:
8 |
9 | ### `npm start`
10 |
11 | Runs the app in the development mode.\
12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
13 |
14 | The page will reload if you make edits.\
15 | You will also see any lint errors in the console.
16 |
17 | ### `npm test`
18 |
19 | Launches the test runner in the interactive watch mode.\
20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
21 |
22 | ### `npm run build`
23 |
24 | Builds the app for production to the `build` folder.\
25 | It correctly bundles React in production mode and optimizes the build for the best performance.
26 |
27 | The build is minified and the filenames include the hashes.\
28 | Your app is ready to be deployed!
29 |
30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
31 |
32 | ### `npm run eject`
33 |
34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
35 |
36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
37 |
38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
39 |
40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
41 |
42 | ## Learn More
43 |
44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
45 |
46 | To learn React, check out the [React documentation](https://reactjs.org/).
47 |
48 | ### Code Splitting
49 |
50 | This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
51 |
52 | ### Analyzing the Bundle Size
53 |
54 | This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
55 |
56 | ### Making a Progressive Web App
57 |
58 | This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
59 |
60 | ### Advanced Configuration
61 |
62 | This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
63 |
64 | ### Deployment
65 |
66 | This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
67 |
68 | ### `npm run build` fails to minify
69 |
70 | This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
71 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/GameWordWasDialog/GameWordWasDialog.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/no-unescaped-entities */
2 | /* eslint-disable react/jsx-one-expression-per-line */
3 | import React, { useEffect, useState } from 'react';
4 | import { useDispatch, useSelector } from 'react-redux';
5 | import { Grid, Dialog, DialogTitle, DialogContent, Slide, makeStyles } from '@material-ui/core';
6 | import Avatar from '../Avatar/Avatar';
7 | import useAudio from '../../hooks/useAudio';
8 | import failSfx from '../../sounds/fail.mp3';
9 | import { RESET_DRAWER } from '../../constants/actionTypes';
10 | import { WS_WORD_WAS } from '../../constants/websocketEvents';
11 | import './GameWordWasDialog.scoped.scss';
12 |
13 | const contentStyles = makeStyles({ root: { overflow: 'hidden' } });
14 | // eslint-disable-next-line react/jsx-props-no-spreading
15 | const Transition = React.forwardRef((props, ref) => );
16 |
17 | const GameWordWasDialog = ({ clearCanvas, clearRevealInterval }) => {
18 | const [soundEnabled, gameChannel, selfId, players] = useSelector(state => [
19 | state.settings.sound,
20 | state.settings.gameChannel,
21 | state.userInfo.id,
22 | state.game.players
23 | ]);
24 | const [wordWas, setWordWasDialog] = useState(null);
25 | const [correctGuessedPlayers, setCorrectGuessedPlayers] = useState({});
26 | const dispatch = useDispatch();
27 | const playFailSfx = useAudio(failSfx);
28 |
29 | const dialogContentClasses = contentStyles();
30 |
31 | useEffect(() => {
32 | let dialogTimer;
33 | gameChannel.on(WS_WORD_WAS, (payload) => {
34 | // Clear word reveal interval
35 | clearRevealInterval();
36 |
37 | dispatch({ type: RESET_DRAWER });
38 | setWordWasDialog(payload.current_word);
39 | setCorrectGuessedPlayers(payload.correct_guessed_players);
40 | dialogTimer = setTimeout(() => {
41 | setWordWasDialog(null);
42 | setCorrectGuessedPlayers([]);
43 | }, 3500);
44 | clearCanvas();
45 | // eslint-disable-next-line camelcase
46 | if (!payload.correct_guessed_players[selfId] && selfId !== payload.drawer_id) return playFailSfx();
47 | });
48 | return () => {
49 | gameChannel.off(WS_WORD_WAS);
50 | if (dialogTimer) clearTimeout(dialogTimer);
51 | };
52 | }, [soundEnabled]);
53 |
54 | return wordWas ? (
55 |
63 |
64 | The word was "{wordWas} "
65 |
66 |
67 |
68 |
69 | {
70 | players
71 | .sort((player1, player2) => (correctGuessedPlayers[player2.id] || 0) - (correctGuessedPlayers[player1.id] || 0))
72 | .map((player) => {
73 | const score = correctGuessedPlayers[player.id] || 0;
74 | const className = score === 0 ? 'could-not-guess' : 'guessed-correctly';
75 |
76 | return (
77 |
78 |
79 |
{`+${score}`}
80 |
81 |
{player.name}
82 |
83 |
84 | );
85 | })
86 | }
87 |
88 |
89 |
90 | ) : null;
91 | };
92 |
93 | export default GameWordWasDialog;
94 |
--------------------------------------------------------------------------------
/pictionary-app/src/images/paint-jar.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
6 |
7 |
9 |
11 |
12 |
13 |
15 |
17 |
19 |
21 |
22 |
23 |
24 |
26 |
28 |
31 |
32 |
33 |
35 |
37 |
38 |
40 |
42 |
43 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
--------------------------------------------------------------------------------
/pictionary-app/src/constants/actionTypes.js:
--------------------------------------------------------------------------------
1 | // == Reducer actions ==
2 |
3 | // settings:
4 | export const TOGGLE_SOUND = 'TOGGLE_SOUND';
5 | export const TOGGLE_DARK_MODE = 'TOGGLE_DARK_MODE';
6 | export const ADD_ALERT = 'ADD_ALERT';
7 | export const CLEAR_ALERT = 'CLEAR_ALERT';
8 | export const SET_LOADING = 'SET_LOADING';
9 | export const CLEAR_LOADING = 'CLEAR_LOADING';
10 | export const SAVE_SOCKET_OBJECT = 'SAVE_SOCKET_OBJECT';
11 | export const SAVE_GAME_CHANNEL = 'SAVE_GAME_CHANNEL';
12 | export const SAVE_GAME_TO_JOIN_ID = 'SAVE_GAME_TO_JOIN_ID';
13 | export const CLEAR_SOCKET = 'CLEAR_SOCKET';
14 |
15 | // userinfo:
16 | export const CHANGE_NAME = 'CHANGE_NAME';
17 | export const CHANGE_AVATAR = 'CHANGE_AVATAR';
18 | export const SAVE_TOKEN = 'SAVE_TOKEN';
19 | export const LOAD_SESSION = 'LOAD_SESSION';
20 |
21 | // game:
22 | export const SAVE_GAME = 'SAVE_GAME';
23 | export const FAILED_SAVE_GAME = 'FAILED_SAVE_GAME';
24 | export const UPDATE_GAME_STATE = 'UPDATE_GAME_STATE';
25 | export const UPDATE_GAME_PLAYERS = 'UPDATE_GAME_PLAYERS';
26 | export const ADMIN_UPDATED = 'ADMIN_UPDATED';
27 | export const REMOVE_PLAYER = 'REMOVE_PLAYER';
28 | export const UPDATE_CANVAS = 'UPDATE_CANVAS';
29 | export const SET_ERASER = 'SET_ERASER';
30 | export const SET_PEN = 'SET_PEN';
31 | export const SET_FILL = 'SET_FILL';
32 |
33 | // gameplay:
34 | export const CHANGE_BRUSH_COLOR = 'CHANGE_BRUSH_COLOR';
35 | export const CHANGE_BRUSH_RADIUS = 'CHANGE_BRUSH_RADIUS';
36 | export const ADD_MESSAGE = 'ADD_MESSAGE';
37 | export const UPDATE_DRAWER = 'UPDATE_DRAWER';
38 | export const UPDATE_ROUND = 'UPDATE_ROUND';
39 | export const UPDATE_SELECTED_WORD = 'UPDATE_SELECTED_WORD';
40 | export const UPDATE_SCORE = 'UPDATE_SCORE';
41 | export const SET_GAME_OVER = 'SET_GAME_OVER';
42 | export const RESET_GAME_STATE = 'RESET_GAME_STATE';
43 | export const HANDLE_UPDATE_SELECTED_WORD = 'HANDLE_UPDATE_SELECTED_WORD';
44 | export const HIDE_ROUND_CHANGE_DIALOG = 'HIDE_ROUND_CHANGE_DIALOG';
45 | export const REVEAL_MORE_CURRENT_WORD = 'REVEAL_MORE_CURRENT_WORD';
46 | export const SET_GAMEPLAY_STATE = 'SET_GAMEPLAY_STATE';
47 | export const RESET_ELAPSED_TIME = 'RESET_ELAPSED_TIME';
48 | export const RESET_DRAWER = 'RESET_DRAWER';
49 | export const ADD_GUESSER = 'ADD_GUESSER';
50 |
51 | // == Saga Actions ==
52 | export const HANDLE_CREATE_USER_SESSION = 'HANDLE_CREATE_USER_SESSION';
53 | export const HANDLE_RESTORE_SESSION = 'HANDLE_RESTORE_SESSION';
54 | export const HANDLE_CREATE_GAME_SESSION = 'HANDLE_CREATE_GAME_SESSION';
55 | export const HANDLE_SAVE_GAME = 'HANDLE_SAVE_GAME';
56 | export const HANDLE_UPDATE_GAME = 'HANDLE_UPDATE_GAME';
57 | export const HANDLE_INIT_SOCKET = 'HANDLE_INIT_SOCKET';
58 | export const HANDLE_INIT_GAME_CHANNEL = 'HANDLE_INIT_GAME_CHANNEL';
59 | export const HANDLE_CREATE_AND_ENTER_GAME_SESSION = 'HANDLE_CREATE_AND_ENTER_GAME_SESSION';
60 | export const HANDLE_JOIN_EXISTING_GAME_SESSION = 'HANDLE_JOIN_EXISTING_GAME_SESSION';
61 | export const HANDLE_CREATE_GAME_FLOW = 'HANDLE_CREATE_GAME_FLOW';
62 | export const HANDLE_JOIN_GAME_FLOW = 'HANDLE_JOIN_GAME_FLOW';
63 | export const HANDLE_FIND_GAME_FLOW = 'HANDLE_FIND_GAME_FLOW';
64 | export const HANDLE_JOIN_GAME_FROM_GAMES_LIST_FLOW = 'HANDLE_JOIN_GAME_FROM_GAMES_LIST_FLOW';
65 | export const HANDLE_GET_GAME_DATA = 'HANDLE_GET_GAME_DATA';
66 | export const HANDLE_GAME_JOIN_SUCCESS = 'HANDLE_GAME_JOIN_SUCCESS';
67 | export const HANDLE_GAME_JOIN_FAIL = 'HANDLE_GAME_JOIN_FAIL';
68 | export const HANDLE_UPDATE_ADMIN = 'HANDLE_UPDATE_ADMIN';
69 | export const HANDLE_KICK_PLAYER = 'HANDLE_KICK_PLAYER';
70 | export const HANDLE_PLAYER_KICKED = 'HANDLE_PLAYER_KICKED';
71 | export const HANDLE_ADMIN_UPDATED = 'HANDLE_ADMIN_UPDATED';
72 | export const HANDLE_START_GAME = 'HANDLE_START_GAME';
73 | export const HANDLE_GAME_STARTED = 'HANDLE_GAME_STARTED';
74 | export const HANDLE_CANVAS_UPDATE = 'HANDLE_CANVAS_UPDATE';
75 | export const HANDLE_CANVAS_UPDATED = 'HANDLE_CANVAS_UPDATED';
76 | export const HANDLE_SEND_MESSAGE = 'HANDLE_SEND_MESSAGE';
77 | export const HANDLE_NEW_MESSAGE = 'HANDLE_NEW_MESSAGE';
78 | export const HANDLE_NEW_DRAWER = 'HANDLE_NEW_DRAWER';
79 |
--------------------------------------------------------------------------------
/pictionary-app/src/components/UserInfo/userInfo.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useState } from 'react';
3 | import { useSelector, useDispatch } from 'react-redux';
4 | import { Container, TextField, Box, ButtonGroup, Button, Modal } from '@material-ui/core';
5 | import { makeStyles } from '@material-ui/core/styles';
6 | import { GiPerspectiveDiceSixFacesRandom } from 'react-icons/gi';
7 | import { FaUserEdit, FaPlay } from 'react-icons/fa';
8 | import { BsHouseFill } from 'react-icons/bs';
9 | import UserAvatar from '../UserAvatar/userAvatar';
10 | import AvatarChooser from '../AvatarChooser/AvatarChooser';
11 | import {
12 | CHANGE_NAME,
13 | HANDLE_CREATE_USER_SESSION,
14 | HANDLE_CREATE_GAME_FLOW,
15 | HANDLE_JOIN_GAME_FLOW,
16 | HANDLE_FIND_GAME_FLOW
17 | } from '../../constants/actionTypes';
18 | import './userInfo.scoped.scss';
19 |
20 | const useStyles = makeStyles(() => ({
21 | modal: {
22 | display: 'flex',
23 | alignItems: 'center',
24 | justifyContent: 'center'
25 | }
26 | }));
27 |
28 | const UserInfo = () => {
29 | const classes = useStyles();
30 | const [random, setRandom] = useState(1);
31 | const [error, setError] = useState(false);
32 | const [showModal, setModal] = useState(false);
33 | const closeModal = () => setModal(false);
34 | const dispatch = useDispatch();
35 | const name = useSelector(state => state.userInfo.name);
36 | const avatar = useSelector(state => state.userInfo.avatar);
37 | const gameToJoinId = useSelector(state => state.settings.gameToJoinId);
38 |
39 | const createAndJoinGame = () => {
40 | if (name !== '') dispatch({ type: HANDLE_CREATE_USER_SESSION, payload: { name, avatar }, flowType: HANDLE_CREATE_GAME_FLOW });
41 | else setError(true);
42 | };
43 |
44 | const joinExistingGame = () => {
45 | if (name !== '') dispatch({ type: HANDLE_CREATE_USER_SESSION, payload: { name, avatar }, flowType: HANDLE_JOIN_GAME_FLOW, gameToJoinId });
46 | else setError(true);
47 | };
48 |
49 | const findNewGame = () => {
50 | if (name !== '') dispatch({ type: HANDLE_CREATE_USER_SESSION, payload: { name, avatar }, flowType: HANDLE_FIND_GAME_FLOW });
51 | else setError(true);
52 | };
53 |
54 | return (
55 |
56 | {
66 | if (event.target.value !== '') setError(false);
67 | dispatch({ type: CHANGE_NAME, payload: event.target.value?.substr(0, 30) });
68 | }}
69 | />
70 |
71 | setRandom(Math.random())} />
72 | setModal(true)} />
73 |
74 |
75 | }
77 | style={{ backgroundColor: '#228b22', color: 'white' }}
78 | onClick={() => (gameToJoinId ? joinExistingGame() : findNewGame())}
79 | >
80 | {gameToJoinId ? 'Join Game !' : 'Play !'}
81 |
82 | }
84 | color="primary"
85 | onClick={createAndJoinGame}
86 | >
87 | Create Game
88 |
89 |
90 |
96 |
99 |
100 |
101 |
102 | );
103 | };
104 |
105 | export default UserInfo;
106 |
--------------------------------------------------------------------------------