├── web ├── views │ ├── app_view.ex │ ├── page_view.ex │ ├── layout_view.ex │ ├── error_view.ex │ └── error_helpers.ex ├── static │ ├── assets │ │ ├── favicon.ico │ │ ├── images │ │ │ ├── phoenix.png │ │ │ └── landing │ │ │ │ └── phoenix.png │ │ └── robots.txt │ ├── js │ │ ├── index.js │ │ ├── app │ │ │ ├── index.js │ │ │ ├── reducers │ │ │ │ ├── index.js │ │ │ │ ├── ws.js │ │ │ │ └── visitors.js │ │ │ ├── routes │ │ │ │ └── index.js │ │ │ ├── store │ │ │ │ └── index.js │ │ │ ├── containers │ │ │ │ ├── App │ │ │ │ │ └── index.js │ │ │ │ └── index.js │ │ │ ├── actions │ │ │ │ └── ws.js │ │ │ └── components │ │ │ │ └── Main │ │ │ │ └── index.js │ │ ├── landing │ │ │ ├── index.js │ │ │ ├── reducers │ │ │ │ ├── index.js │ │ │ │ ├── auth.js │ │ │ │ ├── ws.js │ │ │ │ └── visitors.js │ │ │ ├── routes │ │ │ │ └── index.js │ │ │ ├── containers │ │ │ │ ├── App │ │ │ │ │ └── index.js │ │ │ │ └── index.js │ │ │ ├── store │ │ │ │ └── index.js │ │ │ ├── actions │ │ │ │ ├── auth.js │ │ │ │ └── ws.js │ │ │ └── components │ │ │ │ ├── Main │ │ │ │ └── index.js │ │ │ │ ├── Login │ │ │ │ └── index.js │ │ │ │ └── Registration │ │ │ │ └── index.js │ │ ├── reducers │ │ │ ├── index.js │ │ │ ├── ws.js │ │ │ └── visitors.js │ │ ├── routes │ │ │ └── index.js │ │ ├── containers │ │ │ ├── App │ │ │ │ └── index.js │ │ │ └── index.js │ │ ├── store │ │ │ └── index.js │ │ ├── actions │ │ │ └── ws.js │ │ └── components │ │ │ └── Main │ │ │ └── index.js │ └── styles │ │ ├── app │ │ └── index.less │ │ ├── index.less │ │ └── landing │ │ └── index.less ├── templates │ ├── app │ │ └── index.html.eex │ ├── page │ │ └── index.html.eex │ └── layout │ │ ├── app.html.eex │ │ └── landing.html.eex ├── channels │ ├── visitors_channel.ex │ ├── user_socket.ex │ └── auth_channel.ex ├── router.ex ├── gettext.ex ├── controllers │ ├── app_controller.ex │ └── page_controller.ex ├── web.ex └── models │ └── user.ex ├── lib ├── reph │ ├── repo.ex │ ├── react_io.ex │ ├── guardian │ │ └── serializer.ex │ ├── endpoint.ex │ └── visitors.ex └── reph.ex ├── test ├── views │ ├── layout_view_test.exs │ ├── page_view_test.exs │ └── error_view_test.exs ├── test_helper.exs ├── controllers │ └── page_controller_test.exs ├── models │ └── user_test.exs └── support │ ├── channel_case.ex │ ├── conn_case.ex │ └── model_case.ex ├── priv ├── repo │ ├── migrations │ │ ├── 20160525021310_create_user.exs │ │ └── 20160525021240_create_tokens.exs │ └── seeds.exs └── gettext │ ├── errors.pot │ └── en │ └── LC_MESSAGES │ └── errors.po ├── config ├── test.exs ├── dev.exs ├── config.exs └── prod.exs ├── .gitignore ├── webpack.app.server.config.js ├── webpack.server.config.js ├── webpack.landing.server.config.js ├── package.json ├── mix.exs ├── README.md ├── webpack.app.config.js ├── webpack.config.js ├── webpack.landing.config.js └── mix.lock /web/views/app_view.ex: -------------------------------------------------------------------------------- 1 | defmodule Reph.AppView do 2 | use Reph.Web, :view 3 | end -------------------------------------------------------------------------------- /lib/reph/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Reph.Repo do 2 | use Ecto.Repo, otp_app: :reph 3 | end 4 | -------------------------------------------------------------------------------- /web/views/page_view.ex: -------------------------------------------------------------------------------- 1 | defmodule Reph.PageView do 2 | use Reph.Web, :view 3 | end 4 | -------------------------------------------------------------------------------- /web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule Reph.LayoutView do 2 | use Reph.Web, :view 3 | end 4 | -------------------------------------------------------------------------------- /test/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Reph.LayoutViewTest do 2 | use Reph.ConnCase, async: true 3 | end -------------------------------------------------------------------------------- /test/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Reph.PageViewTest do 2 | use Reph.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /web/static/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chvanikoff/reph2/HEAD/web/static/assets/favicon.ico -------------------------------------------------------------------------------- /web/static/assets/images/phoenix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chvanikoff/reph2/HEAD/web/static/assets/images/phoenix.png -------------------------------------------------------------------------------- /lib/reph/react_io.ex: -------------------------------------------------------------------------------- 1 | defmodule Reph.ReactIO do 2 | use StdJsonIo, otp_app: :reph, script: "node_modules/react-stdio/bin/react-stdio" 3 | end -------------------------------------------------------------------------------- /web/static/assets/images/landing/phoenix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chvanikoff/reph2/HEAD/web/static/assets/images/landing/phoenix.png -------------------------------------------------------------------------------- /web/templates/app/index.html.eex: -------------------------------------------------------------------------------- 1 |
<%= raw @html %>
2 | -------------------------------------------------------------------------------- /web/templates/page/index.html.eex: -------------------------------------------------------------------------------- 1 |
<%= raw @html %>
2 | -------------------------------------------------------------------------------- /web/static/js/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | import Index from "containers"; 5 | 6 | 7 | ReactDOM.render(, document.getElementById("index")); -------------------------------------------------------------------------------- /web/static/js/app/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | import Index from "containers"; 5 | 6 | 7 | ReactDOM.render(, document.getElementById("index")); -------------------------------------------------------------------------------- /web/static/js/landing/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | import Index from "containers"; 5 | 6 | 7 | ReactDOM.render(, document.getElementById("index")); -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start 2 | 3 | Mix.Task.run "ecto.create", ~w(-r Reph.Repo --quiet) 4 | Mix.Task.run "ecto.migrate", ~w(-r Reph.Repo --quiet) 5 | Ecto.Adapters.SQL.begin_test_transaction(Reph.Repo) 6 | 7 | -------------------------------------------------------------------------------- /web/static/assets/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /test/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Reph.PageControllerTest do 2 | use Reph.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 | -------------------------------------------------------------------------------- /web/static/js/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | import { routerReducer } from "react-router-redux"; 3 | 4 | import visitors from "./visitors"; 5 | import ws from "./ws"; 6 | 7 | 8 | export default combineReducers({ 9 | routing: routerReducer, 10 | visitors, 11 | ws 12 | }); -------------------------------------------------------------------------------- /web/static/js/app/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | import { routerReducer } from "react-router-redux"; 3 | 4 | import visitors from "./visitors"; 5 | import ws from "./ws"; 6 | 7 | 8 | export default combineReducers({ 9 | routing: routerReducer, 10 | visitors, 11 | ws 12 | }); -------------------------------------------------------------------------------- /web/static/js/routes/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Route, IndexRoute } from "react-router"; 3 | 4 | import AppContainer from "containers/App"; 5 | import Main from "components/Main"; 6 | 7 | 8 | export default ( 9 | 10 | ); -------------------------------------------------------------------------------- /web/static/js/app/routes/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Route, IndexRoute } from "react-router"; 3 | 4 | import AppContainer from "containers/App"; 5 | import Main from "components/Main"; 6 | 7 | 8 | export default ( 9 | 10 | ); -------------------------------------------------------------------------------- /priv/repo/migrations/20160525021310_create_user.exs: -------------------------------------------------------------------------------- 1 | defmodule Reph.Repo.Migrations.CreateUser do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users) do 6 | add :email, :string 7 | add :password, :string 8 | 9 | timestamps 10 | end 11 | create unique_index(:users, [:email]) 12 | end 13 | end -------------------------------------------------------------------------------- /web/static/js/landing/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | import { routerReducer } from "react-router-redux"; 3 | 4 | import visitors from "./visitors"; 5 | import auth from "./auth"; 6 | import ws from "./ws"; 7 | 8 | 9 | export default combineReducers({ 10 | routing: routerReducer, 11 | visitors, 12 | auth, 13 | ws 14 | }); -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # Reph.Repo.insert!(%Reph.SomeModel{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /lib/reph/guardian/serializer.ex: -------------------------------------------------------------------------------- 1 | defmodule Reph.Guardian.Serializer do 2 | @behaviour Guardian.Serializer 3 | 4 | alias Reph.{Repo, User} 5 | 6 | def for_token(user = %User{}), do: { :ok, "User:#{user.id}" } 7 | def for_token(_), do: { :error, "Unknown resource type" } 8 | 9 | def from_token("User:" <> id), do: { :ok, Repo.get(User, String.to_integer(id)) } 10 | def from_token(_), do: { :error, "Unknown resource type" } 11 | end -------------------------------------------------------------------------------- /web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule Reph.ErrorView do 2 | use Reph.Web, :view 3 | 4 | def render("404.html", _assigns) do 5 | "Page not found" 6 | end 7 | 8 | def render("500.html", _assigns) do 9 | "Server internal error" 10 | end 11 | 12 | # In case no render clause matches or no 13 | # template is found, let's render it as 500 14 | def template_not_found(_template, assigns) do 15 | render "500.html", assigns 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/models/user_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Reph.UserTest do 2 | use Reph.ModelCase 3 | 4 | alias Reph.User 5 | 6 | @valid_attrs %{email: "some content", password: "some content"} 7 | @invalid_attrs %{} 8 | 9 | test "changeset with valid attributes" do 10 | changeset = User.changeset(%User{}, @valid_attrs) 11 | assert changeset.valid? 12 | end 13 | 14 | test "changeset with invalid attributes" do 15 | changeset = User.changeset(%User{}, @invalid_attrs) 16 | refute changeset.valid? 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /priv/repo/migrations/20160525021240_create_tokens.exs: -------------------------------------------------------------------------------- 1 | defmodule Reph.Repo.Migrations.CreateTokens do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:tokens, primary_key: false) do 6 | add :jti, :string, primary_key: true 7 | add :typ, :string 8 | add :aud, :string 9 | add :iss, :string 10 | add :sub, :string 11 | add :exp, :bigint 12 | add :jwt, :text 13 | add :claims, :map 14 | timestamps 15 | end 16 | create unique_index(:tokens, [:jti, :aud]) 17 | end 18 | end -------------------------------------------------------------------------------- /web/static/js/landing/reducers/auth.js: -------------------------------------------------------------------------------- 1 | const initialState = { 2 | login_failed: false, 3 | registration_errors: [] 4 | }; 5 | 6 | export default function reducer(state = initialState, action = {}) { 7 | switch (action.type) { 8 | case "AUTH_REGISTRATION_ERROR": 9 | return { 10 | ...state, 11 | registration_errors: action.errors 12 | }; 13 | case "AUTH_LOGIN_ERROR": 14 | return { 15 | ...state, 16 | login_failed: action.value 17 | } 18 | default: 19 | return state; 20 | } 21 | } -------------------------------------------------------------------------------- /web/static/js/containers/App/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default class App extends React.Component { 4 | render() { 5 | return
6 |
7 | 12 | 13 |
14 | {this.props.children} 15 |
; 16 | } 17 | }; -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.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 :reph, Reph.Endpoint, 6 | http: [port: 4001], 7 | server: false 8 | 9 | # Print only warnings and errors during test 10 | config :logger, level: :warn 11 | 12 | # Configure your database 13 | config :reph, Reph.Repo, 14 | adapter: Ecto.Adapters.Postgres, 15 | username: "postgres", 16 | password: "postgres", 17 | database: "reph_test", 18 | hostname: "localhost", 19 | pool: Ecto.Adapters.SQL.Sandbox 20 | -------------------------------------------------------------------------------- /web/static/js/reducers/ws.js: -------------------------------------------------------------------------------- 1 | const initialState = { 2 | socket: null, 3 | channels: {} 4 | }; 5 | 6 | export default function reducer(state = initialState, action = {}) { 7 | switch (action.type) { 8 | case "SOCKET_CONNECTED": 9 | return { 10 | ...state, 11 | socket: action.socket 12 | }; 13 | case "CHANNEL_JOINED": 14 | return { 15 | ...state, 16 | channels: { 17 | ...state.channels, 18 | [action.name]: action.channel 19 | } 20 | } 21 | default: 22 | return state; 23 | } 24 | } -------------------------------------------------------------------------------- /web/static/js/app/reducers/ws.js: -------------------------------------------------------------------------------- 1 | const initialState = { 2 | socket: null, 3 | channels: {} 4 | }; 5 | 6 | export default function reducer(state = initialState, action = {}) { 7 | switch (action.type) { 8 | case "SOCKET_CONNECTED": 9 | return { 10 | ...state, 11 | socket: action.socket 12 | }; 13 | case "CHANNEL_JOINED": 14 | return { 15 | ...state, 16 | channels: { 17 | ...state.channels, 18 | [action.name]: action.channel 19 | } 20 | } 21 | default: 22 | return state; 23 | } 24 | } -------------------------------------------------------------------------------- /web/static/js/landing/reducers/ws.js: -------------------------------------------------------------------------------- 1 | const initialState = { 2 | socket: null, 3 | channels: {} 4 | }; 5 | 6 | export default function reducer(state = initialState, action = {}) { 7 | switch (action.type) { 8 | case "SOCKET_CONNECTED": 9 | return { 10 | ...state, 11 | socket: action.socket 12 | }; 13 | case "CHANNEL_JOINED": 14 | return { 15 | ...state, 16 | channels: { 17 | ...state.channels, 18 | [action.name]: action.channel 19 | } 20 | } 21 | default: 22 | return state; 23 | } 24 | } -------------------------------------------------------------------------------- /web/static/js/landing/routes/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Route, IndexRoute } from "react-router"; 3 | 4 | import AppContainer from "containers/App"; 5 | import Main from "components/Main"; 6 | import Login from "components/Login"; 7 | import Registration from "components/Registration"; 8 | 9 | 10 | export default ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); -------------------------------------------------------------------------------- /web/templates/layout/app.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Hello Reph! 11 | "> 12 | 13 | 14 | 15 | <%= render @view_module, @view_template, assigns %> 16 | 17 | 18 | -------------------------------------------------------------------------------- /web/channels/visitors_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule Reph.VisitorsChannel do 2 | use Reph.Web, :channel 3 | 4 | def join("visitors", _params, socket) do 5 | send(self, :after_join) 6 | {:ok, socket} 7 | end 8 | 9 | def handle_info(:after_join, socket) do 10 | push(socket, "init", Reph.Visitors.state()) 11 | {:ok, _} = Reph.Visitors.add() 12 | {:noreply, socket} 13 | end 14 | def handle_info(%{event: event}, socket) when event in ["add", "remove"] do 15 | push(socket, event, %{}) 16 | {:noreply, socket} 17 | end 18 | 19 | def terminate(_, _) do 20 | {:ok, _} = Reph.Visitors.remove() 21 | :ok 22 | end 23 | end -------------------------------------------------------------------------------- /web/templates/layout/landing.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Hello Reph! 11 | "> 12 | 13 | 14 | 15 | <%= render @view_module, @view_template, assigns %> 16 | 17 | 18 | -------------------------------------------------------------------------------- /test/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Reph.ErrorViewTest do 2 | use Reph.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(Reph.ErrorView, "404.html", []) == 9 | "Page not found" 10 | end 11 | 12 | test "render 500.html" do 13 | assert render_to_string(Reph.ErrorView, "500.html", []) == 14 | "Server internal error" 15 | end 16 | 17 | test "render any other" do 18 | assert render_to_string(Reph.ErrorView, "505.html", []) == 19 | "Server internal error" 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # App artifacts 2 | /_build 3 | /db 4 | /deps 5 | /*.ez 6 | 7 | # Generate on crash by the VM 8 | erl_crash.dump 9 | 10 | # Static artifacts 11 | /node_modules 12 | 13 | # Since we are building assets from web/static, 14 | # we ignore priv/static. You may want to comment 15 | # this depending on your deployment strategy. 16 | /priv/static/ 17 | 18 | # The config/prod.secret.exs file by default contains sensitive 19 | # data and you should not commit it into version control. 20 | # 21 | # Alternatively, you may comment the line below and commit the 22 | # secrets file as long as you replace its contents by environment 23 | # variables. 24 | /config/prod.secret.exs 25 | -------------------------------------------------------------------------------- /webpack.app.server.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | entry: { 3 | component: "./web/static/js/app/containers/index.js", 4 | }, 5 | output: { 6 | path: "./priv/static/server/js", 7 | filename: "app.js", 8 | library: "app", 9 | libraryTarget: "commonjs2" 10 | }, 11 | module: { 12 | loaders: [{ 13 | test: /\.js$/, 14 | exclude: /node_modules/, 15 | loader: "babel", 16 | query: { 17 | plugins: ["transform-decorators-legacy"], 18 | presets: ["react", "es2015", "stage-2"], 19 | } 20 | }], 21 | }, 22 | resolve: { 23 | extensions: ["", ".js"], 24 | modulesDirectories: ["node_modules", __dirname + "/web/static/js/app"], 25 | } 26 | }; -------------------------------------------------------------------------------- /web/static/js/landing/containers/App/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link, IndexLink } from 'react-router'; 3 | 4 | export default class App extends React.Component { 5 | render() { 6 | return
7 |
8 | 14 | 15 |
16 | {this.props.children} 17 |
; 18 | } 19 | }; -------------------------------------------------------------------------------- /web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule Reph.Router do 2 | use Reph.Web, :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 :app do 13 | plug Guardian.Plug.VerifySession 14 | plug Guardian.Plug.LoadResource 15 | end 16 | 17 | scope "/app", Reph do 18 | pipe_through [:browser, :app] 19 | 20 | get "/logout", AppController, :logout 21 | get "/*path", AppController, :index 22 | end 23 | 24 | scope "/", Reph do 25 | pipe_through :browser # Use the default browser stack 26 | get "/*path", PageController, :index 27 | end 28 | end -------------------------------------------------------------------------------- /webpack.server.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | entry: { 3 | component: "./web/static/js/landing/containers/index.js", 4 | }, 5 | output: { 6 | path: "./priv/static/server/js", 7 | filename: "landing.js", 8 | library: "landing", 9 | libraryTarget: "commonjs2" 10 | }, 11 | module: { 12 | loaders: [{ 13 | test: /\.js$/, 14 | exclude: /node_modules/, 15 | loader: "babel", 16 | query: { 17 | plugins: ["transform-decorators-legacy"], 18 | presets: ["react", "es2015", "stage-2"], 19 | } 20 | }], 21 | }, 22 | resolve: { 23 | extensions: ["", ".js"], 24 | modulesDirectories: ["node_modules", __dirname + "/web/static/js/landing"], 25 | } 26 | }; -------------------------------------------------------------------------------- /webpack.landing.server.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | entry: { 3 | component: "./web/static/js/landing/containers/index.js", 4 | }, 5 | output: { 6 | path: "./priv/static/server/js", 7 | filename: "landing.js", 8 | library: "landing", 9 | libraryTarget: "commonjs2" 10 | }, 11 | module: { 12 | loaders: [{ 13 | test: /\.js$/, 14 | exclude: /node_modules/, 15 | loader: "babel", 16 | query: { 17 | plugins: ["transform-decorators-legacy"], 18 | presets: ["react", "es2015", "stage-2"], 19 | } 20 | }], 21 | }, 22 | resolve: { 23 | extensions: ["", ".js"], 24 | modulesDirectories: ["node_modules", __dirname + "/web/static/js/landing"], 25 | } 26 | }; -------------------------------------------------------------------------------- /web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule Reph.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](http://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import Reph.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](http://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :reph 24 | end 25 | -------------------------------------------------------------------------------- /web/static/js/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from "redux"; 2 | import thunkMiddleware from "redux-thunk"; 3 | 4 | import reducers from "reducers"; 5 | import WSActions from "actions/ws"; 6 | 7 | 8 | const devToolsExt = typeof window === "object" && typeof window.devToolsExtension !== "undefined" 9 | ? window.devToolsExtension() 10 | : f => f; 11 | 12 | export default function configureStore(initialState) { 13 | const store = createStore( 14 | reducers, 15 | initialState, 16 | compose( 17 | applyMiddleware(thunkMiddleware), 18 | devToolsExt 19 | ) 20 | ); 21 | if (typeof window !== "undefined") { 22 | store.dispatch(WSActions.socket_connect()); 23 | store.dispatch(WSActions.channel_join("visitors")); 24 | } 25 | return store; 26 | } -------------------------------------------------------------------------------- /web/static/js/app/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from "redux"; 2 | import thunkMiddleware from "redux-thunk"; 3 | 4 | import reducers from "reducers"; 5 | import WSActions from "actions/ws"; 6 | 7 | 8 | const devToolsExt = typeof window === "object" && typeof window.devToolsExtension !== "undefined" 9 | ? window.devToolsExtension() 10 | : f => f; 11 | 12 | export default function configureStore(initialState) { 13 | const store = createStore( 14 | reducers, 15 | initialState, 16 | compose( 17 | applyMiddleware(thunkMiddleware), 18 | devToolsExt 19 | ) 20 | ); 21 | if (typeof window !== "undefined") { 22 | store.dispatch(WSActions.socket_connect()); 23 | store.dispatch(WSActions.channel_join("visitors")); 24 | } 25 | return store; 26 | } -------------------------------------------------------------------------------- /web/static/js/landing/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from "redux"; 2 | import thunkMiddleware from "redux-thunk"; 3 | 4 | import reducers from "reducers"; 5 | import WSActions from "actions/ws"; 6 | 7 | 8 | const devToolsExt = typeof window === "object" && typeof window.devToolsExtension !== "undefined" 9 | ? window.devToolsExtension() 10 | : f => f; 11 | 12 | export default function configureStore(initialState) { 13 | const store = createStore( 14 | reducers, 15 | initialState, 16 | compose( 17 | applyMiddleware(thunkMiddleware), 18 | devToolsExt 19 | ) 20 | ); 21 | if (typeof window !== "undefined") { 22 | store.dispatch(WSActions.socket_connect()); 23 | store.dispatch(WSActions.channel_join("visitors")); 24 | store.dispatch(WSActions.channel_join("auth")); 25 | } 26 | return store; 27 | } -------------------------------------------------------------------------------- /web/static/js/reducers/visitors.js: -------------------------------------------------------------------------------- 1 | const initialState = { 2 | total: 0, 3 | online: 0, 4 | max_online: 0, 5 | }; 6 | 7 | export default function reducer(state = initialState, action = {}) { 8 | switch (action.type) { 9 | case "VISITORS_INIT": 10 | return { 11 | total: action.total, 12 | online: action.online, 13 | max_online: action.max_online, 14 | }; 15 | case "VISITORS_ADD": 16 | const new_online = state.online + 1; 17 | let max_online = state.max_online; 18 | 19 | if (new_online > max_online) { 20 | max_online = max_online + 1; 21 | } 22 | return { 23 | total: state.total + 1, 24 | online: new_online, 25 | max_online: max_online, 26 | }; 27 | case "VISITORS_REMOVE": 28 | return { 29 | ...state, 30 | online: state.online - 1 31 | } 32 | default: 33 | return state; 34 | } 35 | } -------------------------------------------------------------------------------- /web/static/js/app/reducers/visitors.js: -------------------------------------------------------------------------------- 1 | const initialState = { 2 | total: 0, 3 | online: 0, 4 | max_online: 0, 5 | }; 6 | 7 | export default function reducer(state = initialState, action = {}) { 8 | switch (action.type) { 9 | case "VISITORS_INIT": 10 | return { 11 | total: action.total, 12 | online: action.online, 13 | max_online: action.max_online, 14 | }; 15 | case "VISITORS_ADD": 16 | const new_online = state.online + 1; 17 | let max_online = state.max_online; 18 | 19 | if (new_online > max_online) { 20 | max_online = max_online + 1; 21 | } 22 | return { 23 | total: state.total + 1, 24 | online: new_online, 25 | max_online: max_online, 26 | }; 27 | case "VISITORS_REMOVE": 28 | return { 29 | ...state, 30 | online: state.online - 1 31 | } 32 | default: 33 | return state; 34 | } 35 | } -------------------------------------------------------------------------------- /web/static/js/landing/reducers/visitors.js: -------------------------------------------------------------------------------- 1 | const initialState = { 2 | total: 0, 3 | online: 0, 4 | max_online: 0, 5 | }; 6 | 7 | export default function reducer(state = initialState, action = {}) { 8 | switch (action.type) { 9 | case "VISITORS_INIT": 10 | return { 11 | total: action.total, 12 | online: action.online, 13 | max_online: action.max_online, 14 | }; 15 | case "VISITORS_ADD": 16 | const new_online = state.online + 1; 17 | let max_online = state.max_online; 18 | 19 | if (new_online > max_online) { 20 | max_online = max_online + 1; 21 | } 22 | return { 23 | total: state.total + 1, 24 | online: new_online, 25 | max_online: max_online, 26 | }; 27 | case "VISITORS_REMOVE": 28 | return { 29 | ...state, 30 | online: state.online - 1 31 | } 32 | default: 33 | return state; 34 | } 35 | } -------------------------------------------------------------------------------- /web/controllers/app_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Reph.AppController do 2 | use Reph.Web, :controller 3 | 4 | plug Guardian.Plug.EnsureAuthenticated, handler: __MODULE__ 5 | 6 | def index(conn, %{"path" => path}) do 7 | visitors = Reph.Visitors.state() 8 | initial_state = %{"visitors" => visitors} 9 | props = %{ 10 | "location" => "/app" <> Enum.join(path, "/"), 11 | "initial_state" => initial_state 12 | } 13 | 14 | result = Reph.ReactIO.json_call!(%{ 15 | component: "./priv/static/server/js/app.js", 16 | props: props, 17 | }) 18 | 19 | conn 20 | |> put_layout("app.html") 21 | |> render("index.html", html: result["html"], props: initial_state) 22 | end 23 | 24 | def logout(conn, _params) do 25 | conn 26 | |> Guardian.Plug.sign_out() 27 | |> redirect(to: "/") 28 | end 29 | 30 | def unauthenticated(conn, _params) do 31 | redirect(conn, to: "/") 32 | end 33 | end -------------------------------------------------------------------------------- /web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Reph.PageController do 2 | use Reph.Web, :controller 3 | 4 | def index(conn, %{"token" => token}) do 5 | case Guardian.decode_and_verify(token) do 6 | { :ok, claims } -> 7 | Guardian.revoke!(token) 8 | {:ok, user} = Guardian.serializer.from_token(claims["sub"]) 9 | conn 10 | |> Guardian.Plug.sign_in(user) 11 | |> redirect(to: "/app") 12 | _ -> 13 | redirect(conn, to: "/") 14 | end 15 | end 16 | def index(conn, _params) do 17 | visitors = Reph.Visitors.state() 18 | initial_state = %{"visitors" => visitors} 19 | props = %{ 20 | "location" => conn.request_path, 21 | "initial_state" => initial_state 22 | } 23 | 24 | result = Reph.ReactIO.json_call!(%{ 25 | component: "./priv/static/server/js/landing.js", 26 | props: props, 27 | }) 28 | 29 | conn 30 | |> put_layout("landing.html") 31 | |> render("index.html", html: result["html"], props: initial_state) 32 | end 33 | end -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | webpack = fn(name) -> 4 | {"node", [ 5 | "node_modules/webpack/bin/webpack.js", 6 | "--watch-stdin", 7 | "--colors", 8 | "--config", 9 | "webpack.#{name}.config.js" 10 | ]} 11 | end 12 | 13 | config :reph, Reph.Endpoint, 14 | http: [port: 4000], 15 | debug_errors: true, 16 | code_reloader: true, 17 | check_origin: false, 18 | watchers: ["landing", "landing.server", "app", "app.server"] |> Enum.map(&(webpack.(&1))) 19 | 20 | config :reph, Reph.Endpoint, 21 | live_reload: [ 22 | patterns: [ 23 | ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$}, 24 | ~r{priv/gettext/.*(po)$}, 25 | ~r{web/views/.*(ex)$}, 26 | ~r{web/templates/.*(eex)$} 27 | ] 28 | ] 29 | 30 | config :logger, :console, format: "[$level] $message\n" 31 | 32 | config :phoenix, :stacktrace_depth, 20 33 | 34 | config :reph, Reph.Repo, 35 | adapter: Ecto.Adapters.Postgres, 36 | username: "postgres", 37 | password: "postgres", 38 | database: "reph_dev", 39 | hostname: "localhost", 40 | pool_size: 10 -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "bootstrap": "^3.3.6", 4 | "phoenix": "file:deps/phoenix", 5 | "react": "^15.0.2", 6 | "react-dom": "^15.0.2", 7 | "react-redux": "^4.4.5", 8 | "react-router": "^2.4.0", 9 | "react-router-redux": "^4.0.4", 10 | "redux": "^3.5.2" 11 | }, 12 | "devDependencies": { 13 | "autoprefixer": "^6.3.6", 14 | "babel-core": "^6.8.0", 15 | "babel-loader": "^6.2.4", 16 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 17 | "babel-preset-es2015": "^6.6.0", 18 | "babel-preset-react": "^6.5.0", 19 | "babel-preset-stage-2": "^6.5.0", 20 | "copy-webpack-plugin": "^2.1.3", 21 | "css-loader": "^0.23.1", 22 | "extract-text-webpack-plugin": "^1.0.1", 23 | "file-loader": "^0.8.5", 24 | "imports-loader": "^0.6.5", 25 | "less": "^2.7.0", 26 | "less-loader": "^2.2.3", 27 | "postcss-loader": "^0.9.1", 28 | "react-stdio": "github:chvanikoff/react-stdio", 29 | "redux-thunk": "^2.0.1", 30 | "style-loader": "^0.13.1", 31 | "url-loader": "^0.5.7", 32 | "webpack": "^1.13.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Reph.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 | imports other functionality to make it easier 8 | to build and query models. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with channels 21 | use Phoenix.ChannelTest 22 | 23 | alias Reph.Repo 24 | import Ecto 25 | import Ecto.Changeset 26 | import Ecto.Query, only: [from: 1, from: 2] 27 | 28 | 29 | # The default endpoint for testing 30 | @endpoint Reph.Endpoint 31 | end 32 | end 33 | 34 | setup tags do 35 | unless tags[:async] do 36 | Ecto.Adapters.SQL.restart_test_transaction(Reph.Repo, []) 37 | end 38 | 39 | :ok 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /web/static/js/app/containers/App/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default class App extends React.Component { 4 | render() { 5 | return
6 | 24 | {this.props.children} 25 |
; 26 | } 27 | }; -------------------------------------------------------------------------------- /web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule Reph.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 | if error = form.errors[field] do 13 | content_tag :span, translate_error(error), class: "help-block" 14 | end 15 | end 16 | 17 | @doc """ 18 | Translates an error message using gettext. 19 | """ 20 | def translate_error({msg, opts}) do 21 | # Because error messages were defined within Ecto, we must 22 | # call the Gettext module passing our Gettext backend. We 23 | # also use the "errors" domain as translations are placed 24 | # in the errors.po file. On your own code and templates, 25 | # this could be written simply as: 26 | # 27 | # dngettext "errors", "1 file", "%{count} files", count 28 | # 29 | Gettext.dngettext(Reph.Gettext, "errors", msg, msg, opts[:count], opts) 30 | end 31 | 32 | def translate_error(msg) do 33 | Gettext.dgettext(Reph.Gettext, "errors", msg) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/reph.ex: -------------------------------------------------------------------------------- 1 | defmodule Reph do 2 | use Application 3 | 4 | # See http://elixir-lang.org/docs/stable/elixir/Application.html 5 | # for more information on OTP Applications 6 | def start(_type, _args) do 7 | import Supervisor.Spec, warn: false 8 | 9 | children = [ 10 | # Start the endpoint when the application starts 11 | supervisor(Reph.Endpoint, []), 12 | # Start the Ecto repository 13 | supervisor(Reph.Repo, []), 14 | # Here you could define other workers and supervisors as children 15 | # worker(Reph.Worker, [arg1, arg2, arg3]), 16 | worker(Reph.Visitors, []), 17 | supervisor(Reph.ReactIO, []) 18 | ] 19 | 20 | # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html 21 | # for other strategies and supported options 22 | opts = [strategy: :one_for_one, name: Reph.Supervisor] 23 | Supervisor.start_link(children, opts) 24 | end 25 | 26 | # Tell Phoenix to update the endpoint configuration 27 | # whenever the application is updated. 28 | def config_change(changed, _new, removed) do 29 | Reph.Endpoint.config_change(changed, removed) 30 | :ok 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/reph/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule Reph.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :reph 3 | 4 | socket "/socket", Reph.UserSocket 5 | 6 | # Serve at "/" the static files from "priv/static" directory. 7 | # 8 | # You should set gzip to true if you are running phoenix.digest 9 | # when deploying your static files in production. 10 | plug Plug.Static, 11 | at: "/", from: :reph, gzip: false, 12 | only: ~w(css fonts images js favicon.ico robots.txt) 13 | 14 | # Code reloading can be explicitly enabled under the 15 | # :code_reloader configuration of your endpoint. 16 | if code_reloading? do 17 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 18 | plug Phoenix.LiveReloader 19 | plug Phoenix.CodeReloader 20 | end 21 | 22 | plug Plug.RequestId 23 | plug Plug.Logger 24 | 25 | plug Plug.Parsers, 26 | parsers: [:urlencoded, :multipart, :json], 27 | pass: ["*/*"], 28 | json_decoder: Poison 29 | 30 | plug Plug.MethodOverride 31 | plug Plug.Head 32 | 33 | plug Plug.Session, 34 | store: :cookie, 35 | key: "_reph_key", 36 | signing_salt: "1Cm8tZWK" 37 | 38 | plug Reph.Router 39 | end 40 | -------------------------------------------------------------------------------- /web/static/js/landing/actions/auth.js: -------------------------------------------------------------------------------- 1 | const processToken = (token) => { 2 | window.location = "/?token=" + token; 3 | } 4 | 5 | const authActions = { 6 | register: (params) => { 7 | return (dispatch, getState) => { 8 | const { ws } = getState(); 9 | ws.channels.auth 10 | .push("signup", params) 11 | .receive("ok", (msg) => { 12 | processToken(msg.token); 13 | }) 14 | .receive("error", (msg) => { 15 | dispatch({ 16 | type: 'AUTH_REGISTRATION_ERROR', 17 | errors: msg.errors 18 | }); 19 | }); 20 | } 21 | }, 22 | login: (params) => { 23 | return (dispatch, getState) => { 24 | dispatch({ 25 | type: 'AUTH_LOGIN_ERROR', 26 | value: false 27 | }); 28 | const { ws } = getState(); 29 | ws.channels.auth 30 | .push("login", params) 31 | .receive("ok", (msg) => { 32 | processToken(msg.token); 33 | }) 34 | .receive("error", (msg) => { 35 | dispatch({ 36 | type: 'AUTH_LOGIN_ERROR', 37 | value: true 38 | }); 39 | }); 40 | } 41 | } 42 | }; 43 | 44 | export default authActions; -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Reph.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 | imports other functionality to make it easier 8 | to build and query models. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | 18 | using do 19 | quote do 20 | # Import conveniences for testing with connections 21 | use Phoenix.ConnTest 22 | 23 | alias Reph.Repo 24 | import Ecto 25 | import Ecto.Changeset 26 | import Ecto.Query, only: [from: 1, from: 2] 27 | 28 | import Reph.Router.Helpers 29 | 30 | # The default endpoint for testing 31 | @endpoint Reph.Endpoint 32 | end 33 | end 34 | 35 | setup tags do 36 | unless tags[:async] do 37 | Ecto.Adapters.SQL.restart_test_transaction(Reph.Repo, []) 38 | end 39 | 40 | {:ok, conn: Phoenix.ConnTest.conn()} 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule Reph.UserSocket do 2 | use Phoenix.Socket 3 | 4 | ## Channels 5 | channel "visitors", Reph.VisitorsChannel 6 | channel "auth", Reph.AuthChannel 7 | 8 | 9 | ## Transports 10 | transport :websocket, Phoenix.Transports.WebSocket 11 | # transport :longpoll, Phoenix.Transports.LongPoll 12 | 13 | # Socket params are passed from the client and can 14 | # be used to verify and authenticate a user. After 15 | # verification, you can put default assigns into 16 | # the socket that will be set for all channels, ie 17 | # 18 | # {:ok, assign(socket, :user_id, verified_user_id)} 19 | # 20 | # To deny connection, return `:error`. 21 | # 22 | # See `Phoenix.Token` documentation for examples in 23 | # performing token verification on connect. 24 | def connect(_params, socket) do 25 | {:ok, socket} 26 | end 27 | 28 | # Socket id's are topics that allow you to identify all sockets for a given user: 29 | # 30 | # def id(socket), do: "users_socket:#{socket.assigns.user_id}" 31 | # 32 | # Would allow you to broadcast a "disconnect" event and terminate 33 | # all active sockets and channels for a given user: 34 | # 35 | # Reph.Endpoint.broadcast("users_socket:#{user.id}", "disconnect", %{}) 36 | # 37 | # Returning `nil` makes this socket anonymous. 38 | def id(_socket), do: nil 39 | end 40 | -------------------------------------------------------------------------------- /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 | use Mix.Config 7 | 8 | # Configures the endpoint 9 | config :reph, Reph.Endpoint, 10 | url: [host: "localhost"], 11 | root: Path.dirname(__DIR__), 12 | secret_key_base: "W9Rk2Uu1+dUjpBkwhf7hSy83qJswuPWiBI8VpLTqoljK0sOZ0yAhokodzXLPcul6", 13 | render_errors: [accepts: ~w(html json)], 14 | pubsub: [name: Reph.PubSub, 15 | adapter: Phoenix.PubSub.PG2] 16 | 17 | # Configures Elixir's Logger 18 | config :logger, :console, 19 | format: "$time $metadata[$level] $message\n", 20 | metadata: [:request_id] 21 | 22 | # Import environment specific config. This must remain at the bottom 23 | # of this file so it overrides the configuration defined above. 24 | import_config "#{Mix.env}.exs" 25 | 26 | # Configure phoenix generators 27 | config :phoenix, :generators, 28 | migration: true, 29 | binary_id: false 30 | 31 | config :guardian, Guardian, 32 | issuer: "reph2", 33 | ttl: {30, :days}, 34 | secret_key: "Mqp7VxR2nxq0ghXeHuFfVW1spo5kIuqK7N126MIFxyfSSBnsqNER0rMp1SfyXsrE", 35 | serializer: Reph.Guardian.Serializer, 36 | hooks: GuardianDb 37 | 38 | config :guardian_db, GuardianDb, 39 | repo: Reph.Repo, 40 | schema_name: "tokens" -------------------------------------------------------------------------------- /lib/reph/visitors.ex: -------------------------------------------------------------------------------- 1 | defmodule Reph.Visitors do 2 | use GenServer 3 | 4 | @initial_state %{ 5 | "total" => 0, 6 | "online" => 0, 7 | "max_online" => 0 8 | } 9 | 10 | def start_link() do 11 | GenServer.start_link(__MODULE__, [], name: __MODULE__) 12 | end 13 | 14 | def add, do: GenServer.call(__MODULE__, :add) 15 | 16 | def remove, do: GenServer.call(__MODULE__, :remove) 17 | 18 | def state, do: GenServer.call(__MODULE__, :state) 19 | 20 | def init(_args) do 21 | {:ok, @initial_state} 22 | end 23 | 24 | def handle_call(:add, _, state) do 25 | new_online = state["online"] + 1 26 | max_online = case new_online > state["max_online"] do 27 | true -> new_online 28 | false -> state["max_online"] 29 | end 30 | new_state = %{state | 31 | "total" => state["total"] + 1, 32 | "online" => new_online, 33 | "max_online" => max_online 34 | } 35 | Phoenix.PubSub.broadcast(Reph.PubSub, "visitors", %{event: "add"}) 36 | {:reply, {:ok, new_state}, new_state} 37 | end 38 | def handle_call(:remove, _, state) do 39 | new_state = %{state | 40 | "online" => state["online"] - 1 41 | } 42 | Phoenix.PubSub.broadcast(Reph.PubSub, "visitors", %{event: "remove"}) 43 | {:reply, {:ok, new_state}, new_state} 44 | end 45 | def handle_call(:state, _, state) do 46 | {:reply, state, state} 47 | end 48 | end -------------------------------------------------------------------------------- /web/static/js/containers/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Provider } from "react-redux"; 3 | import { Router, RouterContext, browserHistory, createMemoryHistory, match } from "react-router"; 4 | 5 | import configureStore from "../store"; 6 | import routes from "../routes"; 7 | 8 | 9 | export default class Index extends React.Component { 10 | render() { 11 | let initialState, history, router; 12 | if (typeof window === "undefined") { 13 | initialState = this.props.initial_state; 14 | history = createMemoryHistory(); 15 | match({ routes, location: this.props.location, history }, (err, redirect, props) => { 16 | if (props) { 17 | router = ; 18 | } 19 | // Since it's a very basic app, we don't handle any errors, however in real app you will have do this. 20 | // Please, refer to https://github.com/reactjs/react-router/blob/master/docs/guides/ServerRendering.md 21 | // to find more relevant information. 22 | }); 23 | } else { 24 | initialState = window.__INITIAL_STATE__; 25 | history = browserHistory; 26 | router = 27 | {routes} 28 | ; 29 | } 30 | const store = configureStore(initialState); 31 | 32 | return ( 33 | 34 | {router} 35 | 36 | ); 37 | } 38 | } -------------------------------------------------------------------------------- /web/static/js/app/containers/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Provider } from "react-redux"; 3 | import { Router, RouterContext, browserHistory, createMemoryHistory, match } from "react-router"; 4 | 5 | import configureStore from "../store"; 6 | import routes from "../routes"; 7 | 8 | 9 | export default class Index extends React.Component { 10 | render() { 11 | let initialState, history, router; 12 | if (typeof window === "undefined") { 13 | initialState = this.props.initial_state; 14 | history = createMemoryHistory(); 15 | match({ routes, location: this.props.location, history }, (err, redirect, props) => { 16 | if (props) { 17 | router = ; 18 | } 19 | // Since it's a very basic app, we don't handle any errors, however in real app you will have do this. 20 | // Please, refer to https://github.com/reactjs/react-router/blob/master/docs/guides/ServerRendering.md 21 | // to find more relevant information. 22 | }); 23 | } else { 24 | initialState = window.__INITIAL_STATE__; 25 | history = browserHistory; 26 | router = 27 | {routes} 28 | ; 29 | } 30 | const store = configureStore(initialState); 31 | 32 | return ( 33 | 34 | {router} 35 | 36 | ); 37 | } 38 | } -------------------------------------------------------------------------------- /web/static/js/landing/containers/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Provider } from "react-redux"; 3 | import { Router, RouterContext, browserHistory, createMemoryHistory, match } from "react-router"; 4 | 5 | import configureStore from "../store"; 6 | import routes from "../routes"; 7 | 8 | 9 | export default class Index extends React.Component { 10 | render() { 11 | let initialState, history, router; 12 | if (typeof window === "undefined") { 13 | initialState = this.props.initial_state; 14 | history = createMemoryHistory(); 15 | match({ routes, location: this.props.location, history }, (err, redirect, props) => { 16 | if (props) { 17 | router = ; 18 | } 19 | // Since it's a very basic app, we don't handle any errors, however in real app you will have do this. 20 | // Please, refer to https://github.com/reactjs/react-router/blob/master/docs/guides/ServerRendering.md 21 | // to find more relevant information. 22 | }); 23 | } else { 24 | initialState = window.__INITIAL_STATE__; 25 | history = browserHistory; 26 | router = 27 | {routes} 28 | ; 29 | } 30 | const store = configureStore(initialState); 31 | 32 | return ( 33 | 34 | {router} 35 | 36 | ); 37 | } 38 | } -------------------------------------------------------------------------------- /web/channels/auth_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule Reph.AuthChannel do 2 | use Reph.Web, :channel 3 | 4 | alias Reph.User 5 | 6 | def join("auth", _params, socket) do 7 | {:ok, socket} 8 | end 9 | 10 | def handle_in("login", params, socket) do 11 | response = case User.signin(params) do 12 | {:ok, user} -> 13 | {:ok, %{"token" => get_sl_token(user)}} 14 | {:error, error} -> 15 | {:error, %{"error" => error}} 16 | end 17 | {:reply, response, socket} 18 | end 19 | def handle_in("signup", params, socket) do 20 | response = case User.signup(params) do 21 | {:ok, user} -> 22 | {:ok, %{"token" => get_sl_token(user)}} 23 | {:error, changeset} -> 24 | errors = Enum.reduce(changeset.errors, %{}, fn {field, detail}, acc -> 25 | Map.put(acc, field, render_detail(field, detail)) 26 | end) 27 | {:error, %{"errors" => errors}} 28 | end 29 | {:reply, response, socket} 30 | end 31 | 32 | defp render_detail(_field, {message, values}) do 33 | Enum.reduce(values, message, fn({k, v}, acc) -> 34 | String.replace(acc, "%{#{k}}", to_string(v)) 35 | end) 36 | end 37 | defp render_detail(field, "can't be blank" = message), do: "#{field} - #{message}" 38 | defp render_detail(_field, message), do: message 39 | 40 | defp get_sl_token(user) do 41 | ttl = {10, :seconds} 42 | {:ok, jwt, _full_claims} = Guardian.encode_and_sign(user, :disposable, %{"ttl" => ttl}) 43 | jwt 44 | end 45 | end -------------------------------------------------------------------------------- /web/static/js/landing/components/Main/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default class Main extends React.Component { 4 | render() { 5 | return
6 |
7 |

Welcome to Phoenix!

8 |

A productive web framework that
does not compromise speed and maintainability.

9 |
10 | 11 |
12 |
13 |

Resources

14 | 25 |
26 | 27 |
28 |

Help

29 | 40 |
41 |
42 |
; 43 | } 44 | }; -------------------------------------------------------------------------------- /web/static/js/actions/ws.js: -------------------------------------------------------------------------------- 1 | import { Socket } from 'phoenix'; 2 | 3 | 4 | const setupHandlers = (name, channel, dispatch) => { 5 | switch (name) { 6 | case "visitors": 7 | channel.on("init", (msg) => { 8 | dispatch({ 9 | type: "VISITORS_INIT", 10 | total: msg.total, 11 | online: msg.online, 12 | max_online: msg.max_online 13 | }); 14 | }); 15 | channel.on("add", () => { 16 | dispatch({ 17 | type: "VISITORS_ADD" 18 | }); 19 | }); 20 | channel.on("remove", () => { 21 | dispatch({ 22 | type: "VISITORS_REMOVE" 23 | }); 24 | }); 25 | break; 26 | default: 27 | break; 28 | } 29 | } 30 | 31 | export default { 32 | socket_connect: () => { 33 | return (dispatch) => { 34 | const socket = new Socket('/socket', {}); 35 | socket.connect(); 36 | dispatch({ 37 | type: 'SOCKET_CONNECTED', 38 | socket: socket 39 | }); 40 | } 41 | }, 42 | channel_join: (name, alias = null) => { 43 | alias = alias === null ? name : alias; 44 | return (dispatch, getState) => { 45 | const { ws } = getState(); 46 | if (ws.socket !== null) { 47 | const channel = ws.socket.channel(name); 48 | channel.join().receive('ok', () => { 49 | setupHandlers(alias, channel, dispatch); 50 | dispatch({ 51 | type: 'CHANNEL_JOINED', 52 | name: alias, 53 | channel: channel 54 | }); 55 | }); 56 | } 57 | } 58 | } 59 | }; -------------------------------------------------------------------------------- /web/static/js/app/actions/ws.js: -------------------------------------------------------------------------------- 1 | import { Socket } from 'phoenix'; 2 | 3 | 4 | const setupHandlers = (name, channel, dispatch) => { 5 | switch (name) { 6 | case "visitors": 7 | channel.on("init", (msg) => { 8 | dispatch({ 9 | type: "VISITORS_INIT", 10 | total: msg.total, 11 | online: msg.online, 12 | max_online: msg.max_online 13 | }); 14 | }); 15 | channel.on("add", () => { 16 | dispatch({ 17 | type: "VISITORS_ADD" 18 | }); 19 | }); 20 | channel.on("remove", () => { 21 | dispatch({ 22 | type: "VISITORS_REMOVE" 23 | }); 24 | }); 25 | break; 26 | default: 27 | break; 28 | } 29 | } 30 | 31 | export default { 32 | socket_connect: () => { 33 | return (dispatch) => { 34 | const socket = new Socket('/socket', {}); 35 | socket.connect(); 36 | dispatch({ 37 | type: 'SOCKET_CONNECTED', 38 | socket: socket 39 | }); 40 | } 41 | }, 42 | channel_join: (name, alias = null) => { 43 | alias = alias === null ? name : alias; 44 | return (dispatch, getState) => { 45 | const { ws } = getState(); 46 | if (ws.socket !== null) { 47 | const channel = ws.socket.channel(name); 48 | channel.join().receive('ok', () => { 49 | setupHandlers(alias, channel, dispatch); 50 | dispatch({ 51 | type: 'CHANNEL_JOINED', 52 | name: alias, 53 | channel: channel 54 | }); 55 | }); 56 | } 57 | } 58 | } 59 | }; -------------------------------------------------------------------------------- /web/static/js/landing/actions/ws.js: -------------------------------------------------------------------------------- 1 | import { Socket } from 'phoenix'; 2 | 3 | 4 | const setupHandlers = (name, channel, dispatch) => { 5 | switch (name) { 6 | case "visitors": 7 | channel.on("init", (msg) => { 8 | dispatch({ 9 | type: "VISITORS_INIT", 10 | total: msg.total, 11 | online: msg.online, 12 | max_online: msg.max_online 13 | }); 14 | }); 15 | channel.on("add", () => { 16 | dispatch({ 17 | type: "VISITORS_ADD" 18 | }); 19 | }); 20 | channel.on("remove", () => { 21 | dispatch({ 22 | type: "VISITORS_REMOVE" 23 | }); 24 | }); 25 | break; 26 | default: 27 | break; 28 | } 29 | } 30 | 31 | export default { 32 | socket_connect: () => { 33 | return (dispatch) => { 34 | const socket = new Socket('/socket', {}); 35 | socket.connect(); 36 | dispatch({ 37 | type: 'SOCKET_CONNECTED', 38 | socket: socket 39 | }); 40 | } 41 | }, 42 | channel_join: (name, alias = null) => { 43 | alias = alias === null ? name : alias; 44 | return (dispatch, getState) => { 45 | const { ws } = getState(); 46 | if (ws.socket !== null) { 47 | const channel = ws.socket.channel(name); 48 | channel.join().receive('ok', () => { 49 | setupHandlers(alias, channel, dispatch); 50 | dispatch({ 51 | type: 'CHANNEL_JOINED', 52 | name: alias, 53 | channel: channel 54 | }); 55 | }); 56 | } 57 | } 58 | } 59 | }; -------------------------------------------------------------------------------- /web/static/styles/app/index.less: -------------------------------------------------------------------------------- 1 | @import "~bootstrap/less/bootstrap"; 2 | 3 | @color_1: #fff; 4 | body { 5 | padding-top: 50px; 6 | } 7 | .sub-header { 8 | padding-bottom: 10px; 9 | border-bottom: 1px solid #eee; 10 | } 11 | .navbar-fixed-top { 12 | border: 0; 13 | } 14 | .sidebar { 15 | display: none; 16 | } 17 | .nav-sidebar { 18 | margin-right: -21px; 19 | margin-bottom: 20px; 20 | margin-left: -20px; 21 | >li { 22 | >a { 23 | padding-right: 20px; 24 | padding-left: 20px; 25 | } 26 | } 27 | >.active { 28 | >a { 29 | color: @color_1; 30 | background-color: #428bca; 31 | &:hover { 32 | color: @color_1; 33 | background-color: #428bca; 34 | } 35 | &:focus { 36 | color: @color_1; 37 | background-color: #428bca; 38 | } 39 | } 40 | } 41 | } 42 | .main { 43 | padding: 20px; 44 | .page-header { 45 | margin-top: 0; 46 | } 47 | } 48 | .placeholders { 49 | margin-bottom: 30px; 50 | text-align: center; 51 | h4 { 52 | margin-bottom: 0; 53 | } 54 | } 55 | .placeholder { 56 | margin-bottom: 20px; 57 | img { 58 | display: inline-block; 59 | border-radius: 50%; 60 | } 61 | } 62 | @media (min-width: 768px) { 63 | .sidebar { 64 | position: fixed; 65 | top: 51px; 66 | bottom: 0; 67 | left: 0; 68 | z-index: 1000; 69 | display: block; 70 | padding: 20px; 71 | overflow-x: hidden; 72 | overflow-y: auto; 73 | background-color: #f5f5f5; 74 | border-right: 1px solid #eee; 75 | } 76 | .main { 77 | padding-right: 40px; 78 | padding-left: 40px; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /web/static/styles/index.less: -------------------------------------------------------------------------------- 1 | @import "~bootstrap/less/bootstrap"; 2 | 3 | /* Space out content a bit */ 4 | body, form, ul, table { 5 | margin-top: 20px; 6 | margin-bottom: 20px; 7 | } 8 | 9 | /* Phoenix flash messages */ 10 | .alert:empty { display: none; } 11 | 12 | /* Phoenix inline forms in links and buttons */ 13 | form.link, form.button { 14 | display: inline; 15 | } 16 | 17 | /* Custom page header */ 18 | .header { 19 | border-bottom: 1px solid #e5e5e5; 20 | } 21 | .logo { 22 | width: 519px; 23 | height: 71px; 24 | display: inline-block; 25 | margin-bottom: 1em; 26 | background-image: url("/images/phoenix.png"); 27 | background-size: 519px 71px; 28 | } 29 | 30 | /* Everything but the jumbotron gets side spacing for mobile first views */ 31 | .header, 32 | .marketing { 33 | padding-right: 15px; 34 | padding-left: 15px; 35 | } 36 | 37 | /* Customize container */ 38 | @media (min-width: 768px) { 39 | .container { 40 | max-width: 730px; 41 | } 42 | } 43 | .container-narrow > hr { 44 | margin: 30px 0; 45 | } 46 | 47 | /* Main marketing message */ 48 | .jumbotron { 49 | text-align: center; 50 | border-bottom: 1px solid #e5e5e5; 51 | } 52 | 53 | /* Supporting marketing content */ 54 | .marketing { 55 | margin: 35px 0; 56 | } 57 | 58 | /* Responsive: Portrait tablets and up */ 59 | @media screen and (min-width: 768px) { 60 | /* Remove the padding we set earlier */ 61 | .header, 62 | .marketing { 63 | padding-right: 0; 64 | padding-left: 0; 65 | } 66 | /* Space out the masthead */ 67 | .header { 68 | margin-bottom: 30px; 69 | } 70 | /* Remove the bottom border on the jumbotron for visual effect */ 71 | .jumbotron { 72 | border-bottom: 0; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /test/support/model_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Reph.ModelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | model tests. 5 | 6 | You may define functions here to be used as helpers in 7 | your model tests. See `errors_on/2`'s definition as reference. 8 | 9 | Finally, if the test case interacts with the database, 10 | it cannot be async. For this reason, every test runs 11 | inside a transaction which is reset at the beginning 12 | of the test unless the test case is marked as async. 13 | """ 14 | 15 | use ExUnit.CaseTemplate 16 | 17 | using do 18 | quote do 19 | alias Reph.Repo 20 | 21 | import Ecto 22 | import Ecto.Changeset 23 | import Ecto.Query, only: [from: 1, from: 2] 24 | import Reph.ModelCase 25 | end 26 | end 27 | 28 | setup tags do 29 | unless tags[:async] do 30 | Ecto.Adapters.SQL.restart_test_transaction(Reph.Repo, []) 31 | end 32 | 33 | :ok 34 | end 35 | 36 | @doc """ 37 | Helper for returning list of errors in model when passed certain data. 38 | 39 | ## Examples 40 | 41 | Given a User model that lists `:name` as a required field and validates 42 | `:password` to be safe, it would return: 43 | 44 | iex> errors_on(%User{}, %{password: "password"}) 45 | [password: "is unsafe", name: "is blank"] 46 | 47 | You could then write your assertion like: 48 | 49 | assert {:password, "is unsafe"} in errors_on(%User{}, %{password: "password"}) 50 | 51 | You can also create the changeset manually and retrieve the errors 52 | field directly: 53 | 54 | iex> changeset = User.changeset(%User{}, password: "password") 55 | iex> {:password, "is unsafe"} in changeset.errors 56 | true 57 | """ 58 | def errors_on(model, data) do 59 | model.__struct__.changeset(model, data).errors 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Reph.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :reph, 6 | version: "0.0.1", 7 | elixir: "~> 1.0", 8 | elixirc_paths: elixirc_paths(Mix.env), 9 | compilers: [:phoenix, :gettext] ++ Mix.compilers, 10 | build_embedded: Mix.env == :prod, 11 | start_permanent: Mix.env == :prod, 12 | aliases: aliases, 13 | deps: deps] 14 | end 15 | 16 | # Configuration for the OTP application. 17 | # 18 | # Type `mix help compile.app` for more information. 19 | def application do 20 | [mod: {Reph, []}, 21 | applications: [:phoenix, :phoenix_html, :cowboy, 22 | :logger, :gettext, :phoenix_ecto, :postgrex, 23 | :std_json_io, :guardian, :comeonin]] 24 | end 25 | 26 | # Specifies which paths to compile per environment. 27 | defp elixirc_paths(:test), do: ["lib", "web", "test/support"] 28 | defp elixirc_paths(_), do: ["lib", "web"] 29 | 30 | # Specifies your project dependencies. 31 | # 32 | # Type `mix help deps` for examples and options. 33 | defp deps do 34 | [{:phoenix, "~> 1.1.4"}, 35 | {:postgrex, ">= 0.0.0"}, 36 | {:phoenix_ecto, "~> 2.0"}, 37 | {:phoenix_html, "~> 2.4"}, 38 | {:phoenix_live_reload, "~> 1.0", only: :dev}, 39 | {:gettext, "~> 0.9"}, 40 | {:cowboy, "~> 1.0"}, 41 | {:std_json_io, "~> 0.1"}, 42 | {:comeonin, "~> 2.4"}, 43 | {:guardian, "~> 0.11"}, 44 | {:guardian_db, "~> 0.5"}] 45 | end 46 | 47 | # Aliases are shortcut or tasks specific to the current project. 48 | # For example, to create, migrate and run the seeds file at once: 49 | # 50 | # $ mix ecto.setup 51 | # 52 | # See the documentation for `Mix` for more info on aliases. 53 | defp aliases do 54 | ["ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 55 | "ecto.reset": ["ecto.drop", "ecto.setup"]] 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /web/static/js/app/components/Main/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | import { Link, IndexLink } from 'react-router'; 4 | 5 | class Main extends React.Component { 6 | render() { 7 | const { visitors } = this.props; 8 | const img_url = (number) => { 9 | return "https://placehold.it/200?text=" + number; 10 | }; 11 | return
12 |
13 |
14 |
    15 |
  • Dashboard
  • 16 |
17 |
18 |
19 |

Dashboard

20 | 21 |
22 |
23 | Generic placeholder thumbnail 24 |

Total

25 |
26 |
27 | Generic placeholder thumbnail 28 |

Max. online

29 |
30 |
31 | Generic placeholder thumbnail 32 |

Online

33 |
34 |
35 |
36 |
37 |
; 38 | } 39 | }; 40 | 41 | const mapStateToProps = (state) => { 42 | return { 43 | visitors: state.visitors 44 | }; 45 | }; 46 | export default connect(mapStateToProps)(Main); -------------------------------------------------------------------------------- /web/static/js/landing/components/Login/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | 4 | import authActions from "actions/auth"; 5 | 6 | 7 | class Login extends React.Component { 8 | componentDidMount() { 9 | // Clean up all previously set errors before rendering the element 10 | this.props.dispatch({ 11 | type: 'AUTH_LOGIN_ERROR', 12 | value: false 13 | }); 14 | } 15 | render() { 16 | let error; 17 | if (this.props.login_failed) { 18 | error =
19 |
    Invalid login or password
20 |
; 21 | } 22 | return
23 |
24 |
25 |

Sign in

26 | 27 | 28 | 29 | 30 | 31 | {error} 32 |
33 |
34 |
; 35 | } 36 | 37 | handleSubmit(e) { 38 | e.preventDefault(); 39 | 40 | const { email, password } = this.refs; 41 | const { dispatch } = this.props; 42 | const params = {email: email.value, password: password.value}; 43 | 44 | dispatch(authActions.login(params)); 45 | } 46 | }; 47 | 48 | const mapStateToProps = (state) => { 49 | return { 50 | login_failed: state.auth.login_failed 51 | }; 52 | }; 53 | export default connect(mapStateToProps)(Login); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RePh 2 2 | 3 | ## Tutorial for the app can be found [here](https://medium.com/@chvanikoff/phoenix-react-love-story-reph-2-14a6dcadbbd0) 4 | 5 | ## [Demo](https://reph2.herokuapp.com/) 6 | (The demo is available 18h a day due to Heroku free nodes limitations, if you see `Application Error` message - this is the case) 7 | 8 | React + Redux + Phoenix simple application 9 | 10 | This application is evolution of [RePh](https://github.com/chvanikoff/reph/) and now it provides authorization and different frontend bundles for landing page and application: styles, JS and images are all different. 11 | 12 | Authorization/registration is done via websockets and one-time JWT is used to get user authorized on server side (this is required for server-side rendering where app can use user's data) 13 | 14 | Tech. stack: 15 | 16 | * Phoenix 17 | * Webpack 18 | * React 19 | * Redux 20 | * JWT 21 | 22 | Batteries included: 23 | 24 | * Authorization 25 | * Different bundles for landing page and app itself 26 | * Server-side rendering 27 | * Bootstrap 28 | * LESS (to ease integration of Bootstrap 3) 29 | * [Redux Devtools for Chrome extension](https://github.com/zalmoxisus/redux-devtools-extension) 30 | 31 | To start your Phoenix app: 32 | 33 | * Install dependencies with `mix deps.get` 34 | * Create and migrate your database with `mix ecto.setup` 35 | * Install Node.js dependencies with `npm install` 36 | * Start Phoenix endpoint with `mix phoenix.server` 37 | 38 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 39 | 40 | Ready to run in production? Please [check our deployment guides](http://www.phoenixframework.org/docs/deployment). 41 | 42 | ## Learn more 43 | 44 | * Official website: http://www.phoenixframework.org/ 45 | * Guides: http://phoenixframework.org/docs/overview 46 | * Docs: http://hexdocs.pm/phoenix 47 | * Mailing list: http://groups.google.com/group/phoenix-talk 48 | * Source: https://github.com/phoenixframework/phoenix -------------------------------------------------------------------------------- /web/web.ex: -------------------------------------------------------------------------------- 1 | defmodule Reph.Web do 2 | @moduledoc """ 3 | A module that keeps using definitions for controllers, 4 | views and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use Reph.Web, :controller 9 | use Reph.Web, :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. 17 | """ 18 | 19 | def model do 20 | quote do 21 | use Ecto.Schema 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query, only: [from: 1, from: 2] 26 | end 27 | end 28 | 29 | def controller do 30 | quote do 31 | use Phoenix.Controller 32 | 33 | alias Reph.Repo 34 | import Ecto 35 | import Ecto.Query, only: [from: 1, from: 2] 36 | 37 | import Reph.Router.Helpers 38 | import Reph.Gettext 39 | end 40 | end 41 | 42 | def view do 43 | quote do 44 | use Phoenix.View, root: "web/templates" 45 | 46 | # Import convenience functions from controllers 47 | import Phoenix.Controller, only: [get_csrf_token: 0, get_flash: 2, view_module: 1] 48 | 49 | # Use all HTML functionality (forms, tags, etc) 50 | use Phoenix.HTML 51 | 52 | import Reph.Router.Helpers 53 | import Reph.ErrorHelpers 54 | import Reph.Gettext 55 | end 56 | end 57 | 58 | def router do 59 | quote do 60 | use Phoenix.Router 61 | end 62 | end 63 | 64 | def channel do 65 | quote do 66 | use Phoenix.Channel 67 | 68 | alias Reph.Repo 69 | import Ecto 70 | import Ecto.Query, only: [from: 1, from: 2] 71 | import Reph.Gettext 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 | -------------------------------------------------------------------------------- /web/models/user.ex: -------------------------------------------------------------------------------- 1 | defmodule Reph.User do 2 | use Reph.Web, :model 3 | 4 | alias Reph.Repo 5 | 6 | schema "users" do 7 | field :email, :string 8 | field :password, :string 9 | field :password_plain, :string, virtual: true 10 | field :terms_confirmed, :boolean, virtual: true 11 | 12 | timestamps 13 | end 14 | 15 | @required_fields ~w(email password_plain terms_confirmed) 16 | @optional_fields ~w(password) 17 | 18 | def changeset(model, params \\ :empty) do 19 | model 20 | |> cast(params, @required_fields, @optional_fields) 21 | |> validate_format(:email, ~r/@/, message: "Email format is not valid") 22 | |> validate_length(:password_plain, min: 5, message: "Password should be 5 or more characters long") 23 | |> validate_confirmation(:password_plain, message: "Password confirmation doesn’t match") 24 | |> unique_constraint(:email, message: "This email is already taken") 25 | |> validate_change(:terms_confirmed, fn 26 | _, true -> [] 27 | _, _ -> [terms_confirmed: "Please confirm terms and conditions"] 28 | end) 29 | |> cs_encrypt_password() 30 | end 31 | 32 | def signup(params) do 33 | %__MODULE__{} 34 | |> changeset(params) 35 | |> Repo.insert() 36 | end 37 | 38 | def signin(params) do 39 | email = Map.get(params, "email", "") 40 | password = Map.get(params, "password", "") 41 | __MODULE__ 42 | |> Repo.get_by(email: String.downcase(email)) 43 | |> check_password(password) 44 | end 45 | 46 | defp cs_encrypt_password(%Ecto.Changeset{valid?: true, changes: %{password_plain: pwd}} = cs) do 47 | put_change(cs, :password, Comeonin.Bcrypt.hashpwsalt(pwd)) 48 | end 49 | defp cs_encrypt_password(cs), do: cs 50 | 51 | defp check_password(%__MODULE__{password: hash} = user, password) do 52 | case Comeonin.Bcrypt.checkpw(password, hash) do 53 | true -> {:ok, user} 54 | false -> {:error, "Invalid email or password"} 55 | end 56 | end 57 | defp check_password(nil, _password) do 58 | if Mix.env == :prod do 59 | Comeonin.Bcrypt.dummy_checkpw() 60 | end 61 | {:error, "Invalid email or password"} 62 | end 63 | end -------------------------------------------------------------------------------- /web/static/js/components/Main/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | 4 | 5 | class Main extends React.Component { 6 | render() { 7 | const { visitors } = this.props; 8 | return
9 |
10 |

Welcome to Phoenix!

11 |

A productive web framework that
does not compromise speed and maintainability.

12 |
13 | 14 |
15 |
16 |

Visitors:

17 |
18 |
19 |
20 |
21 |

Total: {visitors.total}

22 |
23 |
24 |

Max. online: {visitors.max_online}

25 |
26 |
27 |

Online: {visitors.online}

28 |
29 |
30 | 31 |
32 |
33 |

Resources

34 | 45 |
46 | 47 |
48 |

Help

49 | 60 |
61 |
62 |
; 63 | } 64 | }; 65 | 66 | const mapStateToProps = (state) => { 67 | return { 68 | visitors: state.visitors 69 | }; 70 | }; 71 | export default connect(mapStateToProps)(Main); -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For production, we configure the host to read the PORT 4 | # from the system environment. Therefore, you will need 5 | # to set PORT=80 before running your server. 6 | # 7 | # You should also configure the url host to something 8 | # meaningful, we use this information when generating URLs. 9 | # 10 | # Finally, we also include the path to a manifest 11 | # containing the digested version of static files. This 12 | # manifest is generated by the mix phoenix.digest task 13 | # which you typically run after static files are built. 14 | config :reph, Reph.Endpoint, 15 | http: [port: {:system, "PORT"}], 16 | url: [host: "example.com", port: 80], 17 | cache_static_manifest: "priv/static/manifest.json" 18 | 19 | # Do not print debug messages in production 20 | config :logger, level: :info 21 | 22 | # ## SSL Support 23 | # 24 | # To get SSL working, you will need to add the `https` key 25 | # to the previous section and set your `:url` port to 443: 26 | # 27 | # config :reph, Reph.Endpoint, 28 | # ... 29 | # url: [host: "example.com", port: 443], 30 | # https: [port: 443, 31 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 32 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")] 33 | # 34 | # Where those two env variables return an absolute path to 35 | # the key and cert in disk or a relative path inside priv, 36 | # for example "priv/ssl/server.key". 37 | # 38 | # We also recommend setting `force_ssl`, ensuring no data is 39 | # ever sent via http, always redirecting to https: 40 | # 41 | # config :reph, Reph.Endpoint, 42 | # force_ssl: [hsts: true] 43 | # 44 | # Check `Plug.SSL` for all available options in `force_ssl`. 45 | 46 | # ## Using releases 47 | # 48 | # If you are doing OTP releases, you need to instruct Phoenix 49 | # to start the server for all endpoints: 50 | # 51 | # config :phoenix, :serve_endpoints, true 52 | # 53 | # Alternatively, you can configure exactly which server to 54 | # start per endpoint: 55 | # 56 | # config :reph, Reph.Endpoint, server: true 57 | # 58 | # You will also need to set the application root to `.` in order 59 | # for the new static assets to be served after a hot upgrade: 60 | # 61 | # config :reph, Reph.Endpoint, root: "." 62 | 63 | # Finally import the config/prod.secret.exs 64 | # which should be versioned separately. 65 | import_config "prod.secret.exs" 66 | -------------------------------------------------------------------------------- /webpack.app.config.js: -------------------------------------------------------------------------------- 1 | const env = process.env.MIX_ENV === "prod" ? "production" : "development"; 2 | const Webpack = require("webpack"); 3 | const ExtractTextPlugin = require("extract-text-webpack-plugin"); 4 | const Autoprefixer = require("autoprefixer"); 5 | 6 | const plugins = { 7 | production: [ 8 | new Webpack.optimize.UglifyJsPlugin({ 9 | compress: {warnings: false} 10 | }) 11 | ], 12 | development: [] 13 | } 14 | 15 | const URLLoader = (dir, mimetype, limit) => { 16 | return "url?" + [ 17 | `limit=${limit}`, 18 | `mimetype=${mimetype}`, 19 | `name=${dir}/app/[name].[ext]` 20 | ].join("&"); 21 | }; 22 | 23 | module.exports = { 24 | entry: [ 25 | "./web/static/js/app/index.js", 26 | "./web/static/styles/app/index.less" 27 | ], 28 | output: { 29 | path: "./priv/static", 30 | filename: "js/app.js", 31 | publicPath: "/", 32 | }, 33 | module: { 34 | loaders: [{ 35 | test: /\.js$/, 36 | exclude: /node_modules/, 37 | loader: "babel", 38 | query: { 39 | plugins: ["transform-decorators-legacy"], 40 | presets: ["react", "es2015", "stage-2"], 41 | } 42 | }, { 43 | test: /\.less$/, 44 | loader: ExtractTextPlugin.extract("style", "css?localIdentName=[hash:base64]!postcss!less") 45 | }, { 46 | test: /\.png$/, 47 | loader: URLLoader("images", "image/png", 10000) 48 | }, { 49 | test: /\.gif$/, 50 | loader: URLLoader("images", "image/gif", 10000) 51 | }, { 52 | test: /\.jpg$/, 53 | loader: URLLoader("images", "image/jpeg", 10000) 54 | }, { 55 | test: /\.(woff|woff2)$/, 56 | loader: URLLoader("fonts", "application/x-font-woff", 10000) 57 | }, { 58 | test: /\.ttf$/, 59 | loader: URLLoader("fonts", "application/x-font-ttf", 10000) 60 | }, { 61 | test: /\.eot$/, 62 | loader: URLLoader("fonts", "application/vnd.ms-fontobject", 10000) 63 | }, { 64 | test: /\.svg$/, 65 | loader: URLLoader("fonts", "image/svg+xml", 10000) 66 | }], 67 | }, 68 | postcss: [ 69 | Autoprefixer({ 70 | browsers: ["last 2 versions"] 71 | }) 72 | ], 73 | resolve: { 74 | extensions: ["", ".js", ".less", ".css"], 75 | modulesDirectories: ["node_modules", __dirname + "/web/static/js/app"], 76 | alias: { 77 | styles: __dirname + "/web/static/styles/app" 78 | } 79 | }, 80 | plugins: [ 81 | // Important to keep React file size down 82 | new Webpack.DefinePlugin({ 83 | "process.env": { 84 | "NODE_ENV": JSON.stringify(env), 85 | }, 86 | }), 87 | new Webpack.optimize.DedupePlugin(), 88 | new ExtractTextPlugin("css/app.css") 89 | ].concat(plugins[env]) 90 | }; -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const env = process.env.MIX_ENV === "prod" ? "production" : "development"; 2 | const Webpack = require("webpack"); 3 | const ExtractTextPlugin = require("extract-text-webpack-plugin"); 4 | const CopyPlugin = require("copy-webpack-plugin"); 5 | const Autoprefixer = require("autoprefixer"); 6 | 7 | const plugins = { 8 | production: [ 9 | new Webpack.optimize.UglifyJsPlugin({ 10 | compress: {warnings: false} 11 | }) 12 | ], 13 | development: [] 14 | } 15 | 16 | const URLLoader = (dir, mimetype, limit) => { 17 | return "url?" + [ 18 | `limit=${limit}`, 19 | `mimetype=${mimetype}`, 20 | `name=${dir}/landing/[name].[ext]` 21 | ].join("&"); 22 | }; 23 | 24 | module.exports = { 25 | entry: [ 26 | "./web/static/js/landing/index.js", 27 | "./web/static/styles/landing/index.less" 28 | ], 29 | output: { 30 | path: "./priv/static", 31 | filename: "js/landing.js", 32 | publicPath: "/", 33 | }, 34 | module: { 35 | loaders: [{ 36 | test: /\.js$/, 37 | exclude: /node_modules/, 38 | loader: "babel", 39 | query: { 40 | plugins: ["transform-decorators-legacy"], 41 | presets: ["react", "es2015", "stage-2"], 42 | } 43 | }, { 44 | test: /\.less$/, 45 | loader: ExtractTextPlugin.extract("style", "css?localIdentName=[hash:base64]!postcss!less") 46 | }, { 47 | test: /\.png$/, 48 | loader: URLLoader("images", "image/png", 10000) 49 | }, { 50 | test: /\.gif$/, 51 | loader: URLLoader("images", "image/gif", 10000) 52 | }, { 53 | test: /\.jpg$/, 54 | loader: URLLoader("images", "image/jpeg", 10000) 55 | }, { 56 | test: /\.(woff|woff2)$/, 57 | loader: URLLoader("fonts", "application/x-font-woff", 10000) 58 | }, { 59 | test: /\.ttf$/, 60 | loader: URLLoader("fonts", "application/x-font-ttf", 10000) 61 | }, { 62 | test: /\.eot$/, 63 | loader: URLLoader("fonts", "application/vnd.ms-fontobject", 10000) 64 | }, { 65 | test: /\.svg$/, 66 | loader: URLLoader("fonts", "image/svg+xml", 10000) 67 | }], 68 | }, 69 | postcss: [ 70 | Autoprefixer({ 71 | browsers: ["last 2 versions"] 72 | }) 73 | ], 74 | resolve: { 75 | extensions: ["", ".js", ".less", ".css"], 76 | modulesDirectories: ["node_modules", __dirname + "/web/static/js/landing"], 77 | alias: { 78 | styles: __dirname + "/web/static/styles/landing" 79 | } 80 | }, 81 | plugins: [ 82 | // Important to keep React file size down 83 | new Webpack.DefinePlugin({ 84 | "process.env": { 85 | "NODE_ENV": JSON.stringify(env), 86 | }, 87 | }), 88 | new Webpack.optimize.DedupePlugin(), 89 | new ExtractTextPlugin("css/landing.css"), 90 | new CopyPlugin([{from: "./web/static/assets"}]) 91 | ].concat(plugins[env]) 92 | }; -------------------------------------------------------------------------------- /web/static/styles/landing/index.less: -------------------------------------------------------------------------------- 1 | @import "~bootstrap/less/bootstrap"; 2 | 3 | /* Space out content a bit */ 4 | body, form, ul, table { 5 | margin-top: 20px; 6 | margin-bottom: 20px; 7 | } 8 | 9 | /* Phoenix flash messages */ 10 | .alert:empty { display: none; } 11 | 12 | /* Phoenix inline forms in links and buttons */ 13 | form.link, form.button { 14 | display: inline; 15 | } 16 | 17 | /* Custom page header */ 18 | .header { 19 | border-bottom: 1px solid #e5e5e5; 20 | } 21 | .logo { 22 | width: 519px; 23 | height: 71px; 24 | display: inline-block; 25 | margin-bottom: 1em; 26 | background-image: url("/images/landing/phoenix.png"); 27 | background-size: 519px 71px; 28 | } 29 | 30 | /* Everything but the jumbotron gets side spacing for mobile first views */ 31 | .header, 32 | .marketing { 33 | padding-right: 15px; 34 | padding-left: 15px; 35 | } 36 | 37 | /* Customize container */ 38 | @media (min-width: 768px) { 39 | .container { 40 | max-width: 730px; 41 | } 42 | } 43 | .container-narrow > hr { 44 | margin: 30px 0; 45 | } 46 | 47 | /* Main marketing message */ 48 | .jumbotron { 49 | text-align: center; 50 | border-bottom: 1px solid #e5e5e5; 51 | } 52 | 53 | /* Supporting marketing content */ 54 | .marketing { 55 | margin: 35px 0; 56 | } 57 | 58 | /* Responsive: Portrait tablets and up */ 59 | @media screen and (min-width: 768px) { 60 | /* Remove the padding we set earlier */ 61 | .header, 62 | .marketing { 63 | padding-right: 0; 64 | padding-left: 0; 65 | } 66 | /* Space out the masthead */ 67 | .header { 68 | margin-bottom: 30px; 69 | } 70 | /* Remove the bottom border on the jumbotron for visual effect */ 71 | .jumbotron { 72 | border-bottom: 0; 73 | } 74 | } 75 | 76 | .form-auth { 77 | margin: 0 auto; 78 | max-width: 330px; 79 | padding: 15px; 80 | .form-auth-heading { 81 | margin-bottom: 10px; 82 | } 83 | .checkbox { 84 | font-weight: normal; 85 | margin-bottom: 10px; 86 | } 87 | .form-control { 88 | -moz-box-sizing: border-box; 89 | -webkit-box-sizing: border-box; 90 | box-sizing: border-box; 91 | font-size: 16px; 92 | height: auto; 93 | padding: 10px; 94 | position: relative; 95 | &:focus { 96 | z-index: 2; 97 | } 98 | } 99 | .input-first { 100 | border-bottom-left-radius: 0; 101 | border-bottom-right-radius: 0; 102 | margin-bottom: -1px; 103 | } 104 | .input-inner { 105 | border-bottom-left-radius: 0; 106 | border-bottom-right-radius: 0; 107 | border-top-left-radius: 0; 108 | border-top-right-radius: 0; 109 | margin-bottom: -1px; 110 | } 111 | .input-last { 112 | border-top-left-radius: 0; 113 | border-top-right-radius: 0; 114 | margin-bottom: 10px; 115 | } 116 | } -------------------------------------------------------------------------------- /webpack.landing.config.js: -------------------------------------------------------------------------------- 1 | const env = process.env.MIX_ENV === "prod" ? "production" : "development"; 2 | const Webpack = require("webpack"); 3 | const ExtractTextPlugin = require("extract-text-webpack-plugin"); 4 | const CopyPlugin = require("copy-webpack-plugin"); 5 | const Autoprefixer = require("autoprefixer"); 6 | 7 | const plugins = { 8 | production: [ 9 | new Webpack.optimize.UglifyJsPlugin({ 10 | compress: {warnings: false} 11 | }) 12 | ], 13 | development: [] 14 | } 15 | 16 | const URLLoader = (dir, mimetype, limit) => { 17 | return "url?" + [ 18 | `limit=${limit}`, 19 | `mimetype=${mimetype}`, 20 | `name=${dir}/landing/[name].[ext]` 21 | ].join("&"); 22 | }; 23 | 24 | module.exports = { 25 | entry: [ 26 | "./web/static/js/landing/index.js", 27 | "./web/static/styles/landing/index.less" 28 | ], 29 | output: { 30 | path: "./priv/static", 31 | filename: "js/landing.js", 32 | publicPath: "/", 33 | }, 34 | module: { 35 | loaders: [{ 36 | test: /\.js$/, 37 | exclude: /node_modules/, 38 | loader: "babel", 39 | query: { 40 | plugins: ["transform-decorators-legacy"], 41 | presets: ["react", "es2015", "stage-2"], 42 | } 43 | }, { 44 | test: /\.less$/, 45 | loader: ExtractTextPlugin.extract("style", "css?localIdentName=[hash:base64]!postcss!less") 46 | }, { 47 | test: /\.png$/, 48 | loader: URLLoader("images", "image/png", 10000) 49 | }, { 50 | test: /\.gif$/, 51 | loader: URLLoader("images", "image/gif", 10000) 52 | }, { 53 | test: /\.jpg$/, 54 | loader: URLLoader("images", "image/jpeg", 10000) 55 | }, { 56 | test: /\.(woff|woff2)$/, 57 | loader: URLLoader("fonts", "application/x-font-woff", 10000) 58 | }, { 59 | test: /\.ttf$/, 60 | loader: URLLoader("fonts", "application/x-font-ttf", 10000) 61 | }, { 62 | test: /\.eot$/, 63 | loader: URLLoader("fonts", "application/vnd.ms-fontobject", 10000) 64 | }, { 65 | test: /\.svg$/, 66 | loader: URLLoader("fonts", "image/svg+xml", 10000) 67 | }], 68 | }, 69 | postcss: [ 70 | Autoprefixer({ 71 | browsers: ["last 2 versions"] 72 | }) 73 | ], 74 | resolve: { 75 | extensions: ["", ".js", ".less", ".css"], 76 | modulesDirectories: ["node_modules", __dirname + "/web/static/js/landing"], 77 | alias: { 78 | styles: __dirname + "/web/static/styles/landing" 79 | } 80 | }, 81 | plugins: [ 82 | // Important to keep React file size down 83 | new Webpack.DefinePlugin({ 84 | "process.env": { 85 | "NODE_ENV": JSON.stringify(env), 86 | }, 87 | }), 88 | new Webpack.optimize.DedupePlugin(), 89 | new ExtractTextPlugin("css/landing.css"), 90 | new CopyPlugin([{from: "./web/static/assets"}]) 91 | ].concat(plugins[env]) 92 | }; -------------------------------------------------------------------------------- /priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This file is a PO Template file. `msgid`s here are often extracted from 2 | ## source code; add new translations manually only if they're dynamic 3 | ## translations that can't be statically extracted. Run `mix 4 | ## gettext.extract` to bring this file up to date. Leave `msgstr`s empty as 5 | ## changing them here as no effect; edit them in PO (`.po`) files instead. 6 | 7 | ## From Ecto.Changeset.cast/4 8 | msgid "can't be blank" 9 | msgstr "" 10 | 11 | ## From Ecto.Changeset.unique_constraint/3 12 | msgid "has already been taken" 13 | msgstr "" 14 | 15 | ## From Ecto.Changeset.put_change/3 16 | msgid "is invalid" 17 | msgstr "" 18 | 19 | ## From Ecto.Changeset.validate_format/3 20 | msgid "has invalid format" 21 | msgstr "" 22 | 23 | ## From Ecto.Changeset.validate_subset/3 24 | msgid "has an invalid entry" 25 | msgstr "" 26 | 27 | ## From Ecto.Changeset.validate_exclusion/3 28 | msgid "is reserved" 29 | msgstr "" 30 | 31 | ## From Ecto.Changeset.validate_confirmation/3 32 | msgid "does not match confirmation" 33 | msgstr "" 34 | 35 | ## From Ecto.Changeset.no_assoc_constraint/3 36 | msgid "is still associated to this entry" 37 | msgstr "" 38 | 39 | msgid "are still associated to this entry" 40 | msgstr "" 41 | 42 | ## From Ecto.Changeset.validate_length/3 43 | msgid "should be %{count} character(s)" 44 | msgid_plural "should be %{count} character(s)" 45 | msgstr[0] "" 46 | msgstr[1] "" 47 | 48 | msgid "should have %{count} item(s)" 49 | msgid_plural "should have %{count} item(s)" 50 | msgstr[0] "" 51 | msgstr[1] "" 52 | 53 | msgid "should be at least %{count} character(s)" 54 | msgid_plural "should be at least %{count} character(s)" 55 | msgstr[0] "" 56 | msgstr[1] "" 57 | 58 | msgid "should have at least %{count} item(s)" 59 | msgid_plural "should have at least %{count} item(s)" 60 | msgstr[0] "" 61 | msgstr[1] "" 62 | 63 | msgid "should be at most %{count} character(s)" 64 | msgid_plural "should be at most %{count} character(s)" 65 | msgstr[0] "" 66 | msgstr[1] "" 67 | 68 | msgid "should have at most %{count} item(s)" 69 | msgid_plural "should have at most %{count} item(s)" 70 | msgstr[0] "" 71 | msgstr[1] "" 72 | 73 | ## From Ecto.Changeset.validate_number/3 74 | msgid "must be less than %{count}" 75 | msgid_plural "must be less than %{count}" 76 | msgstr[0] "" 77 | msgstr[1] "" 78 | 79 | msgid "must be greater than %{count}" 80 | msgid_plural "must be greater than %{count}" 81 | msgstr[0] "" 82 | msgstr[1] "" 83 | 84 | msgid "must be less than or equal to %{count}" 85 | msgid_plural "must be less than or equal to %{count}" 86 | msgstr[0] "" 87 | msgstr[1] "" 88 | 89 | msgid "must be greater than or equal to %{count}" 90 | msgid_plural "must be greater than or equal to %{count}" 91 | msgstr[0] "" 92 | msgstr[1] "" 93 | 94 | msgid "must be equal to %{count}" 95 | msgid_plural "must be equal to %{count}" 96 | msgstr[0] "" 97 | msgstr[1] "" 98 | -------------------------------------------------------------------------------- /priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. Do not add, change, or 2 | ## remove `msgid`s manually here as they're tied to the ones in the 3 | ## corresponding POT file (with the same domain). Use `mix gettext.extract 4 | ## --merge` or `mix gettext.merge` to merge POT files into PO files. 5 | msgid "" 6 | msgstr "" 7 | "Language: en\n" 8 | 9 | ## From Ecto.Changeset.cast/4 10 | msgid "can't be blank" 11 | msgstr "" 12 | 13 | ## From Ecto.Changeset.unique_constraint/3 14 | msgid "has already been taken" 15 | msgstr "" 16 | 17 | ## From Ecto.Changeset.put_change/3 18 | msgid "is invalid" 19 | msgstr "" 20 | 21 | ## From Ecto.Changeset.validate_format/3 22 | msgid "has invalid format" 23 | msgstr "" 24 | 25 | ## From Ecto.Changeset.validate_subset/3 26 | msgid "has an invalid entry" 27 | msgstr "" 28 | 29 | ## From Ecto.Changeset.validate_exclusion/3 30 | msgid "is reserved" 31 | msgstr "" 32 | 33 | ## From Ecto.Changeset.validate_confirmation/3 34 | msgid "does not match confirmation" 35 | msgstr "" 36 | 37 | ## From Ecto.Changeset.no_assoc_constraint/3 38 | msgid "is still associated to this entry" 39 | msgstr "" 40 | 41 | msgid "are still associated to this entry" 42 | msgstr "" 43 | 44 | ## From Ecto.Changeset.validate_length/3 45 | msgid "should be %{count} character(s)" 46 | msgid_plural "should be %{count} character(s)" 47 | msgstr[0] "" 48 | msgstr[1] "" 49 | 50 | msgid "should have %{count} item(s)" 51 | msgid_plural "should have %{count} item(s)" 52 | msgstr[0] "" 53 | msgstr[1] "" 54 | 55 | msgid "should be at least %{count} character(s)" 56 | msgid_plural "should be at least %{count} character(s)" 57 | msgstr[0] "" 58 | msgstr[1] "" 59 | 60 | msgid "should have at least %{count} item(s)" 61 | msgid_plural "should have at least %{count} item(s)" 62 | msgstr[0] "" 63 | msgstr[1] "" 64 | 65 | msgid "should be at most %{count} character(s)" 66 | msgid_plural "should be at most %{count} character(s)" 67 | msgstr[0] "" 68 | msgstr[1] "" 69 | 70 | msgid "should have at most %{count} item(s)" 71 | msgid_plural "should have at most %{count} item(s)" 72 | msgstr[0] "" 73 | msgstr[1] "" 74 | 75 | ## From Ecto.Changeset.validate_number/3 76 | msgid "must be less than %{count}" 77 | msgid_plural "must be less than %{count}" 78 | msgstr[0] "" 79 | msgstr[1] "" 80 | 81 | msgid "must be greater than %{count}" 82 | msgid_plural "must be greater than %{count}" 83 | msgstr[0] "" 84 | msgstr[1] "" 85 | 86 | msgid "must be less than or equal to %{count}" 87 | msgid_plural "must be less than or equal to %{count}" 88 | msgstr[0] "" 89 | msgstr[1] "" 90 | 91 | msgid "must be greater than or equal to %{count}" 92 | msgid_plural "must be greater than or equal to %{count}" 93 | msgstr[0] "" 94 | msgstr[1] "" 95 | 96 | msgid "must be equal to %{count}" 97 | msgid_plural "must be equal to %{count}" 98 | msgstr[0] "" 99 | msgstr[1] "" 100 | -------------------------------------------------------------------------------- /web/static/js/landing/components/Registration/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | 4 | import authActions from 'actions/auth'; 5 | 6 | 7 | class Registration extends React.Component { 8 | componentDidMount() { 9 | // Clean up all previously set errors before rendering the element 10 | this.props.dispatch({ 11 | type: 'AUTH_REGISTRATION_ERROR', 12 | errors: [] 13 | }); 14 | } 15 | 16 | render() { 17 | const errors = this.buildErrors(this.props.errors); 18 | return
19 |
20 |
21 |

Registration

22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 32 |
33 | 34 | {errors} 35 |
36 |
37 |
; 38 | } 39 | 40 | handleSubmit(e) { 41 | e.preventDefault(); 42 | 43 | const data = { 44 | email: this.refs.email.value, 45 | password_plain: this.refs.password.value, 46 | password_plain_confirmation: this.refs.password_confirmation.value, 47 | terms_confirmed: this.refs.terms_confirmation.checked 48 | }; 49 | const { dispatch } = this.props; 50 | 51 | dispatch(authActions.register(data)); 52 | } 53 | 54 | buildErrors(errors) { 55 | if (errors == []) { 56 | return null; 57 | } 58 | let errors_elements = []; 59 | let key, el, err_key; 60 | let i = 0; 61 | for (err_key in errors) { 62 | i++; 63 | key = `signupError${i}`; 64 | el =
  • {errors[err_key]}
  • ; 65 | errors_elements.push(el); 66 | } 67 | return
    68 |
      {errors_elements}
    69 |
    ; 70 | } 71 | }; 72 | 73 | const mapStateToProps = (state) => { 74 | return { 75 | errors: state.auth.registration_errors 76 | }; 77 | }; 78 | export default connect(mapStateToProps)(Registration); -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], []}, 2 | "comeonin": {:hex, :comeonin, "2.4.0", "2dc7526b6352f7cf03de79c1e19b5154ac021da48b0c1790a12841ef5ca00340", [:mix, :make, :make], []}, 3 | "connection": {:hex, :connection, "1.0.2", "f4a06dd3ecae4141aa66f94ce92ea4c4b8753069472814932f1cadbc3078ab80", [:mix], []}, 4 | "cowboy": {:hex, :cowboy, "1.0.4", "a324a8df9f2316c833a470d918aaf73ae894278b8aa6226ce7a9bf699388f878", [:rebar, :make], [{:cowlib, "~> 1.0.0", [hex: :cowlib, optional: false]}, {:ranch, "~> 1.0", [hex: :ranch, optional: false]}]}, 5 | "cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], []}, 6 | "db_connection": {:hex, :db_connection, "0.2.5", "3e5e28019e0ec744345568d22a2f5206109bff0e2571f4d7819e0d14cf955f3e", [:mix], [{:sbroker, "~> 0.7", [hex: :sbroker, optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, optional: true]}, {:connection, "~> 1.0.2", [hex: :connection, optional: false]}]}, 7 | "decimal": {:hex, :decimal, "1.1.2", "79a769d4657b2d537b51ef3c02d29ab7141d2b486b516c109642d453ee08e00c", [:mix], []}, 8 | "ecto": {:hex, :ecto, "1.1.5", "5c181b737a650117466b0540aa323198a0fb9d518e52718ac2c299f4047187e1", [:mix], [{:sbroker, "~> 0.7", [hex: :sbroker, optional: true]}, {:postgrex, "~> 0.11.0", [hex: :postgrex, optional: true]}, {:poolboy, "~> 1.4", [hex: :poolboy, optional: false]}, {:poison, "~> 1.0", [hex: :poison, optional: true]}, {:mariaex, "~> 0.5.0 or ~> 0.6.0", [hex: :mariaex, optional: true]}, {:decimal, "~> 1.0", [hex: :decimal, optional: false]}]}, 9 | "fs": {:hex, :fs, "0.9.2", "ed17036c26c3f70ac49781ed9220a50c36775c6ca2cf8182d123b6566e49ec59", [:rebar], []}, 10 | "gettext": {:hex, :gettext, "0.11.0", "80c1dd42d270482418fa158ec5ba073d2980e3718bacad86f3d4ad71d5667679", [:mix], []}, 11 | "guardian": {:hex, :guardian, "0.11.1", "8555d357b9499188e90e12063001255ea808d895f1fe94fc10f7b5ac52b0619a", [:mix], [{:uuid, ">=1.1.1", [hex: :uuid, optional: false]}, {:poison, ">= 1.3.0", [hex: :poison, optional: false]}, {:plug, "~> 1.0", [hex: :plug, optional: false]}, {:jose, "~> 1.6", [hex: :jose, optional: false]}]}, 12 | "guardian_db": {:hex, :guardian_db, "0.5.0", "19c0a6bb85cd46a23c8f0768b4f8a916148107d525daf2aa272dc5387b536223", [:mix], [{:postgrex, ">= 0.9.1", [hex: :postgrex, optional: true]}, {:guardian, "~> 0.10", [hex: :guardian, optional: false]}, {:ecto, ">= 0.11.0", [hex: :ecto, optional: false]}]}, 13 | "jose": {:hex, :jose, "1.7.5", "f19f890f7aaa1261230af691e2f1e0fc60bf44bce7d34c86022d0efa8875628a", [:mix, :rebar], [{:base64url, "~> 0.0.1", [hex: :base64url, optional: false]}]}, 14 | "phoenix": {:hex, :phoenix, "1.1.4", "65809fba92eb94377372a5fb5a561197654bb8406e773cc47ca1a031bbe58019", [:mix], [{:poison, "~> 1.5 or ~> 2.0", [hex: :poison, optional: false]}, {:plug, "~> 1.0", [hex: :plug, optional: false]}, {:cowboy, "~> 1.0", [hex: :cowboy, optional: true]}]}, 15 | "phoenix_ecto": {:hex, :phoenix_ecto, "2.0.1", "24532a443b1686150fd14819a4a366eccf41135da2a2ed6ae2f78597828ecb0a", [:mix], [{:poison, "~> 1.3", [hex: :poison, optional: true]}, {:phoenix_html, "~> 2.2", [hex: :phoenix_html, optional: true]}, {:ecto, "~> 1.1.2", [hex: :ecto, optional: false]}]}, 16 | "phoenix_html": {:hex, :phoenix_html, "2.5.1", "631053f9e345fecb5c87d9e0ccd807f7266d27e2ee4269817067af425fd81ba8", [:mix], [{:plug, "~> 0.13 or ~> 1.0", [hex: :plug, optional: false]}]}, 17 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.0.5", "829218c4152ba1e9848e2bf8e161fcde6b4ec679a516259442561d21fde68d0b", [:mix], [{:phoenix, "~> 1.0 or ~> 1.2-rc", [hex: :phoenix, optional: false]}, {:fs, "~> 0.9.1", [hex: :fs, optional: false]}]}, 18 | "plug": {:hex, :plug, "1.1.4", "2eee0e85ad420db96e075b3191d3764d6fff61422b101dc5b02e9cce99cacfc7", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, optional: true]}]}, 19 | "poison": {:hex, :poison, "1.5.2", "560bdfb7449e3ddd23a096929fb9fc2122f709bcc758b2d5d5a5c7d0ea848910", [:mix], []}, 20 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], []}, 21 | "porcelain": {:hex, :porcelain, "2.0.1", "9c3db2b47d8cf6879c0d9ac79db8657333974a88faff09e856569e00c1b5e119", [:mix], []}, 22 | "postgrex": {:hex, :postgrex, "0.11.1", "f48af70c0a58b9bfd1aaa456ec4273624554cfb837726b6a7f0701da4a94b2dd", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, optional: false]}, {:db_connection, "~> 0.2", [hex: :db_connection, optional: false]}, {:connection, "~> 1.0", [hex: :connection, optional: false]}]}, 23 | "ranch": {:hex, :ranch, "1.2.1", "a6fb992c10f2187b46ffd17ce398ddf8a54f691b81768f9ef5f461ea7e28c762", [:make], []}, 24 | "std_json_io": {:hex, :std_json_io, "0.1.0", "e099eeb8b62048cfff2c4d0edc6ea69f3d31e95f1d2df88657500f8109c183ed", [:mix], [{:porcelain, "~> 2.0", [hex: :porcelain, optional: false]}, {:poolboy, "~> 1.5.1", [hex: :poolboy, optional: false]}, {:poison, "~> 1.5.0", [hex: :poison, optional: false]}, {:fs, "~> 0.9.1", [hex: :fs, optional: false]}]}, 25 | "uuid": {:hex, :uuid, "1.1.3", "06ca38801a1a95b751701ca40716bb97ddf76dfe7e26da0eec7dba636740d57a", [:mix], []}} 26 | --------------------------------------------------------------------------------