├── test ├── test_helper.exs ├── stopwatch_web │ ├── views │ │ ├── page_view_test.exs │ │ ├── layout_view_test.exs │ │ └── error_view_test.exs │ └── live │ │ ├── stopwatch_js_test.exs │ │ └── stopwatch_live_test.exs ├── stopwatch │ ├── timer_server_test.exs │ └── timer_test.exs └── support │ └── conn_case.ex ├── rel └── overlays │ └── bin │ ├── server.bat │ └── server ├── lib ├── stopwatch_web │ ├── views │ │ ├── page_view.ex │ │ ├── stopwatch_view.ex │ │ ├── layout_view.ex │ │ ├── error_view.ex │ │ └── error_helpers.ex │ ├── templates │ │ ├── layout │ │ │ ├── app.html.heex │ │ │ ├── live.html.heex │ │ │ └── root.html.heex │ │ ├── stopwatch │ │ │ ├── stopwatch_js.html.heex │ │ │ └── stopwatch.html.heex │ │ └── page │ │ │ └── index.html.heex │ ├── router.ex │ ├── live │ │ ├── stopwatch_live.ex │ │ └── stopwatch_live_js.ex │ ├── endpoint.ex │ └── telemetry.ex ├── stopwatch.ex ├── stopwatch │ ├── timer_db.ex │ ├── timer.ex │ ├── application.ex │ └── timer_server.ex └── stopwatch_web.ex ├── priv └── static │ ├── favicon.ico │ ├── images │ └── phoenix.png │ ├── robots.txt │ └── assets │ └── app.css ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── coveralls.json ├── config ├── test.exs ├── config.exs ├── prod.exs ├── runtime.exs └── dev.exs ├── fly.toml ├── .dockerignore ├── mix.exs ├── assets ├── css │ ├── app.css │ └── phoenix.css ├── js │ └── app.js └── vendor │ └── topbar.js ├── Dockerfile ├── mix.lock ├── LICENSE └── README.md /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /rel/overlays/bin/server.bat: -------------------------------------------------------------------------------- 1 | set PHX_SERVER=true 2 | call "%~dp0\stopwatch" start 3 | -------------------------------------------------------------------------------- /lib/stopwatch_web/views/page_view.ex: -------------------------------------------------------------------------------- 1 | defmodule StopwatchWeb.PageView do 2 | use StopwatchWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dwyl/phoenix-liveview-stopwatch/HEAD/priv/static/favicon.ico -------------------------------------------------------------------------------- /lib/stopwatch_web/templates/layout/app.html.heex: -------------------------------------------------------------------------------- 1 |
2 | <%= @inner_content %> 3 |
4 | -------------------------------------------------------------------------------- /rel/overlays/bin/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd -P -- "$(dirname -- "$0")" 3 | PHX_SERVER=true exec ./stopwatch start 4 | -------------------------------------------------------------------------------- /lib/stopwatch_web/views/stopwatch_view.ex: -------------------------------------------------------------------------------- 1 | defmodule StopwatchWeb.StopwatchView do 2 | use StopwatchWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /priv/static/images/phoenix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dwyl/phoenix-liveview-stopwatch/HEAD/priv/static/images/phoenix.png -------------------------------------------------------------------------------- /test/stopwatch_web/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StopwatchWeb.PageViewTest do 2 | use StopwatchWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | /doc 5 | /.fetch 6 | erl_crash.dump 7 | *.ez 8 | *.beam 9 | /config/*.secret.exs 10 | .elixir_ls/ 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: monthly 7 | time: "17:00" 8 | timezone: Europe/London 9 | -------------------------------------------------------------------------------- /priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://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 | -------------------------------------------------------------------------------- /lib/stopwatch.ex: -------------------------------------------------------------------------------- 1 | defmodule Stopwatch do 2 | @moduledoc """ 3 | Stopwatch keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /lib/stopwatch_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule StopwatchWeb.LayoutView do 2 | use StopwatchWeb, :view 3 | 4 | # Phoenix LiveDashboard is available only in development by default, 5 | # so we instruct Elixir to not warn if the dashboard route is missing. 6 | @compile {:no_warn_undefined, {Routes, :live_dashboard_path, 2}} 7 | end 8 | -------------------------------------------------------------------------------- /test/stopwatch_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StopwatchWeb.LayoutViewTest do 2 | use StopwatchWeb.ConnCase, async: true 3 | 4 | # When testing helpers, you may want to import Phoenix.HTML and 5 | # use functions such as safe_to_string() to convert the helper 6 | # result into an HTML string. 7 | # import Phoenix.HTML 8 | end 9 | -------------------------------------------------------------------------------- /lib/stopwatch_web/templates/stopwatch/stopwatch_js.html.heex: -------------------------------------------------------------------------------- 1 |

00:00:00

2 | 3 | <%= if @timer_status == :stopped do %> 4 | 5 | <% end %> 6 | 7 | <%= if @timer_status == :running do %> 8 | 9 | <% end %> 10 | 11 | 12 | -------------------------------------------------------------------------------- /lib/stopwatch_web/templates/stopwatch/stopwatch.html.heex: -------------------------------------------------------------------------------- 1 |

<%= @time |> Time.truncate(:second) |> Time.to_string() %>

2 | <%= if @timer_status == :stopped do %> 3 | 4 | <% end %> 5 | 6 | <%= if @timer_status == :running do %> 7 | 8 | <% end %> 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "coverage_options": { 3 | "minimum_coverage": 100 4 | }, 5 | "skip_files": [ 6 | "test/", 7 | "lib/stopwatch/application.ex", 8 | "lib/stopwatch/release.ex", 9 | "lib/stopwatch_web.ex", 10 | "lib/stopwatch_web/views/error_helpers.ex", 11 | "lib/stopwatch_web/channels/user_socket.ex", 12 | "lib/stopwatch_web/telemetry.ex", 13 | "lib/stopwatch/timer_db.ex" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /lib/stopwatch_web/templates/layout/live.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 5 | 6 | 9 | 10 | <%= @inner_content %> 11 |
12 | -------------------------------------------------------------------------------- /test/stopwatch_web/live/stopwatch_js_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StopwatchW.StopwatchJSTest do 2 | use StopwatchWeb.ConnCase 3 | import Phoenix.LiveViewTest 4 | 5 | test "stopwatch is ticking", %{conn: conn} do 6 | conn = get(conn, "/stopwatch-js") 7 | assert html_response(conn, 200) =~ "00:00:00" 8 | 9 | {:ok, view, _html} = live(conn) 10 | render_click(view, "start") =~ "stop" 11 | render_click(view, "stop") =~ "start" 12 | render_click(view, "reset") =~ "start" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/stopwatch_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StopwatchWeb.ErrorViewTest do 2 | use StopwatchWeb.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(StopwatchWeb.ErrorView, "404.html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(StopwatchWeb.ErrorView, "500.html", []) == "Internal Server Error" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :stopwatch, StopwatchWeb.Endpoint, 6 | http: [ip: {127, 0, 0, 1}, port: 4002], 7 | secret_key_base: "UtF2HDPYn/l1NRTblw0I39IO1EFK8L+arMD4WD2bK0TafaRURFqwYI3sOoujn0+d", 8 | server: false 9 | 10 | # Print only warnings and errors during test 11 | config :logger, level: :warning 12 | 13 | # Initialize plugs at runtime for faster test compilation 14 | config :phoenix, :plug_init_mode, :runtime 15 | -------------------------------------------------------------------------------- /lib/stopwatch_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule StopwatchWeb.Router do 2 | use StopwatchWeb, :router 3 | 4 | pipeline :browser do 5 | plug(:accepts, ["html"]) 6 | plug(:fetch_session) 7 | plug(:fetch_live_flash) 8 | plug(:put_root_layout, {StopwatchWeb.LayoutView, :root}) 9 | plug(:protect_from_forgery) 10 | plug(:put_secure_browser_headers) 11 | end 12 | 13 | scope "/", StopwatchWeb do 14 | pipe_through(:browser) 15 | 16 | live("/", StopwatchLive) 17 | live("/stopwatch-js", StopwatchLiveJS) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/stopwatch_web/live/stopwatch_live_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StopwatchW.StopwatchLiveTest do 2 | use StopwatchWeb.ConnCase 3 | import Phoenix.LiveViewTest 4 | 5 | test "stopwatch is ticking", %{conn: conn} do 6 | conn = get(conn, "/") 7 | assert html_response(conn, 200) =~ "

00:00:00

" 8 | 9 | {:ok, view, _html} = live(conn) 10 | render_click(view, "start") 11 | Process.sleep(1000) 12 | assert render_click(view, "stop") =~ "00:00:01" 13 | 14 | # reset 15 | assert render_click(view, "reset") =~ "Start" 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/stopwatch_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule StopwatchWeb.ErrorView do 2 | use StopwatchWeb, :view 3 | 4 | # If you want to customize a particular status code 5 | # for a certain format, you may uncomment below. 6 | # def render("500.html", _assigns) do 7 | # "Internal Server Error" 8 | # end 9 | 10 | # By default, Phoenix returns the status message from 11 | # the template name. For example, "404.html" becomes 12 | # "Not Found". 13 | def template_not_found(template, _assigns) do 14 | Phoenix.Controller.status_message_from_template(template) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/stopwatch/timer_server_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Stopwatch.TimerServerTest do 2 | use ExUnit.Case, async: true 3 | alias Stopwatch.TimerServer 4 | 5 | setup context do 6 | timer = start_supervised!({Stopwatch.TimerServer, name: context.test}) 7 | %{timer: timer} 8 | end 9 | 10 | test "GenServer timer is working", %{timer: timer} do 11 | assert {:stopped, ~T[00:00:00]} == TimerServer.get_timer_state(timer) 12 | assert :running == TimerServer.start_timer(timer) 13 | assert :stopped == TimerServer.stop_timer(timer) 14 | assert :reset == TimerServer.reset(timer) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/stopwatch_web/templates/layout/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= csrf_meta_tag() %> 8 | <.live_title prefix="LiveView Stopwatch – "> 9 | <%= assigns[:page_title] || "Welcome" %> 10 | 11 | 12 | 13 | 14 | 15 | <%= @inner_content %> 16 | 17 | 18 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for liveview-stopwatch on 2022-06-15T14:36:55+01:00 2 | 3 | app = "liveview-stopwatch" 4 | 5 | kill_signal = "SIGTERM" 6 | kill_timeout = 5 7 | processes = [] 8 | 9 | [env] 10 | PHX_HOST = "liveview-stopwatch.fly.dev" 11 | PORT = "8080" 12 | 13 | [experimental] 14 | allowed_public_ports = [] 15 | auto_rollback = true 16 | 17 | [[services]] 18 | http_checks = [] 19 | internal_port = 8080 20 | processes = ["app"] 21 | protocol = "tcp" 22 | script_checks = [] 23 | 24 | [services.concurrency] 25 | hard_limit = 25 26 | soft_limit = 20 27 | type = "connections" 28 | 29 | [[services.ports]] 30 | force_https = true 31 | handlers = ["http"] 32 | port = 80 33 | 34 | [[services.ports]] 35 | handlers = ["tls", "http"] 36 | port = 443 37 | 38 | [[services.tcp_checks]] 39 | grace_period = "1s" 40 | interval = "15s" 41 | restart_limit = 0 42 | timeout = "2s" 43 | -------------------------------------------------------------------------------- /lib/stopwatch_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule StopwatchWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | # use Phoenix.HTML 7 | 8 | @doc """ 9 | Generates tag for inlined form input errors. 10 | """ 11 | def error_tag(_form, _field) do 12 | # Enum.map(Keyword.get_values(form.errors, field), fn error -> 13 | # content_tag(:span, translate_error(error), 14 | # class: "invalid-feedback", 15 | # phx_feedback_for: input_name(form, field) 16 | # ) 17 | # end) 18 | end 19 | 20 | @doc """ 21 | Translates an error message. 22 | """ 23 | def translate_error({msg, opts}) do 24 | # Because the error messages we show in our forms and APIs 25 | # are defined inside Ecto, we need to translate them dynamically. 26 | Enum.reduce(opts, msg, fn {key, value}, acc -> 27 | String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end) 28 | end) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/stopwatch/timer_db.ex: -------------------------------------------------------------------------------- 1 | defmodule Stopwatch.TimerDB do 2 | use Agent 3 | alias Phoenix.PubSub 4 | 5 | def start_link(opts) do 6 | Agent.start_link(fn -> {:stopped, nil, nil} end, opts) 7 | end 8 | 9 | def start_timer(db) do 10 | start = DateTime.utc_now() |> DateTime.to_unix(:millisecond) 11 | Agent.update(db, fn _ -> {:running, start, nil} end) 12 | end 13 | 14 | def stop_timer(db) do 15 | stop = DateTime.utc_now() |> DateTime.to_unix(:millisecond) 16 | Agent.update(db, fn {_, start, _} -> {:stopped, start, stop} end) 17 | end 18 | 19 | def get_timer_state(db) do 20 | Agent.get(db, fn state -> state end) 21 | end 22 | 23 | def reset_timer(db) do 24 | Agent.update(db, fn _state -> {:stopped, nil, nil} end) 25 | end 26 | 27 | def subscribe() do 28 | PubSub.subscribe(Stopwatch.PubSub, "liveview_stopwatch_js") 29 | end 30 | 31 | def notify() do 32 | PubSub.broadcast(Stopwatch.PubSub, "liveview_stopwatch_js", :timer_updated) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | 15 | name: Build and test 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Set up Elixir 21 | uses: erlef/setup-beam@v1 22 | with: 23 | elixir-version: '1.14.2' # Define the elixir version [required] 24 | otp-version: '25.1.2' # Define the OTP version [required] 25 | - name: Restore dependencies cache 26 | uses: actions/cache@v4 27 | with: 28 | path: deps 29 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 30 | restore-keys: ${{ runner.os }}-mix- 31 | - name: Install dependencies 32 | run: mix deps.get 33 | - name: Run Tests 34 | run: mix coveralls.json 35 | - name: Upload coverage to Codecov 36 | uses: codecov/codecov-action@v4 37 | with: 38 | token: ${{ secrets.CODECOV_TOKEN }} 39 | -------------------------------------------------------------------------------- /test/stopwatch/timer_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Stopwatch.TimerTest do 2 | use ExUnit.Case, async: true 3 | 4 | setup context do 5 | start_supervised!({Stopwatch.Timer, name: context.test}) 6 | %{timer: context.test} 7 | end 8 | 9 | test "Timer agent is working!", %{timer: timer} do 10 | assert {:stopped, ~T[00:00:00]} == Stopwatch.Timer.get_timer_state(timer) 11 | assert :ok = Stopwatch.Timer.start_timer(timer) 12 | assert :ok = Stopwatch.Timer.tick(timer) 13 | assert {:running, time} = Stopwatch.Timer.get_timer_state(timer) 14 | assert Time.truncate(time, :second) == ~T[00:00:01] 15 | assert :ok = Stopwatch.Timer.stop_timer(timer) 16 | assert {:stopped, _time} = Stopwatch.Timer.get_timer_state(timer) 17 | end 18 | 19 | test "Timer is reset", %{timer: timer} do 20 | assert :ok = Stopwatch.Timer.start_timer(timer) 21 | :ok = Stopwatch.Timer.tick(timer) 22 | :ok = Stopwatch.Timer.tick(timer) 23 | {:running, time} = Stopwatch.Timer.get_timer_state(timer) 24 | assert Time.truncate(time, :second) == ~T[00:00:02] 25 | Stopwatch.Timer.reset(timer) 26 | assert {:stopped, ~T[00:00:00]} == Stopwatch.Timer.get_timer_state(timer) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/stopwatch/timer.ex: -------------------------------------------------------------------------------- 1 | defmodule Stopwatch.Timer do 2 | use Agent 3 | alias Phoenix.PubSub 4 | 5 | def start_link(opts) do 6 | Agent.start_link(fn -> {:stopped, ~T[00:00:00]} end, opts) 7 | end 8 | 9 | def get_timer_state(timer) do 10 | Agent.get(timer, fn state -> state end) 11 | end 12 | 13 | def start_timer(timer) do 14 | Agent.update(timer, fn {_timer_status, time} -> {:running, time} end) 15 | notify() 16 | end 17 | 18 | def stop_timer(timer) do 19 | Agent.update(timer, fn {_timer_status, time} -> {:stopped, time} end) 20 | notify() 21 | end 22 | 23 | def tick(timer) do 24 | Agent.update(timer, fn {timer_status, timer} -> 25 | {timer_status, Time.add(timer, 1, :second)} 26 | end) 27 | 28 | notify() 29 | end 30 | 31 | def reset(timer) do 32 | Agent.update(timer, fn _state -> {:stopped, ~T[00:00:00]} end) 33 | notify() 34 | end 35 | 36 | # coveralls-ignore-start 37 | def subscribe() do 38 | PubSub.subscribe(Stopwatch.PubSub, "liveview_stopwatch") 39 | end 40 | 41 | # coveralls-ignore-end 42 | 43 | def notify() do 44 | PubSub.broadcast(Stopwatch.PubSub, "liveview_stopwatch", :timer_updated) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule StopwatchWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use StopwatchWeb.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with connections 23 | import Plug.Conn 24 | import Phoenix.ConnTest 25 | import StopwatchWeb.ConnCase 26 | 27 | alias StopwatchWeb.Router.Helpers, as: Routes 28 | 29 | # The default endpoint for testing 30 | @endpoint StopwatchWeb.Endpoint 31 | end 32 | end 33 | 34 | setup _tags do 35 | {:ok, conn: Phoenix.ConnTest.build_conn()} 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/stopwatch/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Stopwatch.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | children = [ 11 | # Start the Telemetry supervisor 12 | StopwatchWeb.Telemetry, 13 | # Start the PubSub system 14 | {Phoenix.PubSub, name: Stopwatch.PubSub}, 15 | # Start the Endpoint (http/https) 16 | StopwatchWeb.Endpoint, 17 | # Start a worker by calling: Stopwatch.Worker.start_link(arg) 18 | # {Stopwatch.Worker, arg} 19 | # {Stopwatch.Timer, name: Stopwatch.Timer} 20 | {Stopwatch.TimerServer, name: Stopwatch.TimerServer}, 21 | {Stopwatch.TimerDB, name: Stopwatch.TimerDB} 22 | ] 23 | 24 | # See https://hexdocs.pm/elixir/Supervisor.html 25 | # for other strategies and supported options 26 | opts = [strategy: :one_for_one, name: Stopwatch.Supervisor] 27 | Supervisor.start_link(children, opts) 28 | end 29 | 30 | # Tell Phoenix to update the endpoint configuration 31 | # whenever the application is updated. 32 | @impl true 33 | def config_change(changed, _new, removed) do 34 | StopwatchWeb.Endpoint.config_change(changed, removed) 35 | :ok 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/stopwatch_web/templates/page/index.html.heex: -------------------------------------------------------------------------------- 1 |
2 |

Welcome to Phoenix!

3 |

Peace of mind from prototype to production

4 |
5 | 6 |
7 |
8 |

Resources

9 | 20 |
21 |
22 |

Help

23 | 40 |
41 |
42 | -------------------------------------------------------------------------------- /lib/stopwatch_web/live/stopwatch_live.ex: -------------------------------------------------------------------------------- 1 | defmodule StopwatchWeb.StopwatchLive do 2 | use StopwatchWeb, :live_view 3 | alias Stopwatch.TimerServer 4 | 5 | def mount(_params, _session, socket) do 6 | if connected?(socket), do: TimerServer.subscribe() 7 | 8 | {timer_status, time} = TimerServer.get_timer_state(Stopwatch.TimerServer) 9 | {:ok, assign(socket, time: time, timer_status: timer_status)} 10 | end 11 | 12 | def render(assigns) do 13 | Phoenix.View.render(StopwatchWeb.StopwatchView, "stopwatch.html", assigns) 14 | end 15 | 16 | def handle_event("start", _value, socket) do 17 | :running = TimerServer.start_timer(Stopwatch.TimerServer) 18 | TimerServer.notify() 19 | {:noreply, socket} 20 | end 21 | 22 | def handle_event("stop", _value, socket) do 23 | :stopped = TimerServer.stop_timer(Stopwatch.TimerServer) 24 | TimerServer.notify() 25 | {:noreply, socket} 26 | end 27 | 28 | def handle_event("reset", _value, socket) do 29 | :reset = TimerServer.reset(Stopwatch.TimerServer) 30 | TimerServer.notify() 31 | {:noreply, socket} 32 | end 33 | 34 | def handle_info(:timer_updated, socket) do 35 | {timer_status, time} = TimerServer.get_timer_state(Stopwatch.TimerServer) 36 | 37 | {:noreply, assign(socket, time: time, timer_status: timer_status)} 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | # Configures the endpoint 11 | config :stopwatch, StopwatchWeb.Endpoint, 12 | url: [host: "localhost"], 13 | render_errors: [view: StopwatchWeb.ErrorView, accepts: ~w(html json), layout: false], 14 | pubsub_server: Stopwatch.PubSub, 15 | live_view: [signing_salt: "aLdXkckn"] 16 | 17 | # Configure esbuild (the version is required) 18 | config :esbuild, 19 | version: "0.14.29", 20 | default: [ 21 | args: 22 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), 23 | cd: Path.expand("../assets", __DIR__), 24 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 25 | ] 26 | 27 | # Configures Elixir's Logger 28 | config :logger, :console, 29 | format: "$time $metadata[$level] $message\n", 30 | metadata: [:request_id] 31 | 32 | # Use Jason for JSON parsing in Phoenix 33 | config :phoenix, :json_library, Jason 34 | 35 | # Import environment specific config. This must remain at the bottom 36 | # of this file so it overrides the configuration defined above. 37 | import_config "#{config_env()}.exs" 38 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # This file excludes paths from the Docker build context. 2 | # 3 | # By default, Docker's build context includes all files (and folders) in the 4 | # current directory. Even if a file isn't copied into the container it is still sent to 5 | # the Docker daemon. 6 | # 7 | # There are multiple reasons to exclude files from the build context: 8 | # 9 | # 1. Prevent nested folders from being copied into the container (ex: exclude 10 | # /assets/node_modules when copying /assets) 11 | # 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc) 12 | # 3. Avoid sending files containing sensitive information 13 | # 14 | # More information on using .dockerignore is available here: 15 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file 16 | 17 | .dockerignore 18 | 19 | # Ignore git, but keep git HEAD and refs to access current commit hash if needed: 20 | # 21 | # $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat 22 | # d0b8727759e1e0e7aa3d41707d12376e373d5ecc 23 | .git 24 | !.git/HEAD 25 | !.git/refs 26 | 27 | # Common development/test artifacts 28 | /cover/ 29 | /doc/ 30 | /test/ 31 | /tmp/ 32 | .elixir_ls 33 | 34 | # Mix artifacts 35 | /_build/ 36 | /deps/ 37 | *.ez 38 | 39 | # Generated on crash by the VM 40 | erl_crash.dump 41 | 42 | # Static artifacts - These should be fetched and built inside the Docker image 43 | /assets/node_modules/ 44 | /priv/static/assets/ 45 | /priv/static/cache_manifest.json 46 | -------------------------------------------------------------------------------- /lib/stopwatch_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule StopwatchWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :stopwatch 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_stopwatch_key", 10 | signing_salt: "XlKk51Ac" 11 | ] 12 | 13 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 14 | 15 | # Serve at "/" the static files from "priv/static" directory. 16 | # 17 | # You should set gzip to true if you are running phx.digest 18 | # when deploying your static files in production. 19 | plug Plug.Static, 20 | at: "/", 21 | from: :stopwatch, 22 | gzip: false, 23 | only: ~w(assets fonts images favicon.ico robots.txt) 24 | 25 | # Code reloading can be explicitly enabled under the 26 | # :code_reloader configuration of your endpoint. 27 | if code_reloading? do 28 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 29 | plug Phoenix.LiveReloader 30 | plug Phoenix.CodeReloader 31 | end 32 | 33 | plug Plug.RequestId 34 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 35 | 36 | plug Plug.Parsers, 37 | parsers: [:urlencoded, :multipart, :json], 38 | pass: ["*/*"], 39 | json_decoder: Phoenix.json_library() 40 | 41 | plug Plug.MethodOverride 42 | plug Plug.Head 43 | plug Plug.Session, @session_options 44 | plug StopwatchWeb.Router 45 | end 46 | -------------------------------------------------------------------------------- /lib/stopwatch_web/live/stopwatch_live_js.ex: -------------------------------------------------------------------------------- 1 | defmodule StopwatchWeb.StopwatchLiveJS do 2 | use StopwatchWeb, :live_view 3 | alias Stopwatch.TimerDB 4 | 5 | def mount(_params, _session, socket) do 6 | if connected?(socket), do: TimerDB.subscribe() 7 | 8 | # {timer_status, time} = TimerServer.get_timer_state(Stopwatch.TimerServer) 9 | # {:ok, assign(socket, time: time, timer_status: timer_status)} 10 | {status, start, stop} = TimerDB.get_timer_state(Stopwatch.TimerDB) 11 | # if running 12 | TimerDB.notify() 13 | {:ok, assign(socket, timer_status: status, start: start, stop: stop)} 14 | end 15 | 16 | def render(assigns) do 17 | Phoenix.View.render(StopwatchWeb.StopwatchView, "stopwatch_js.html", assigns) 18 | end 19 | 20 | def handle_event("start", _value, socket) do 21 | TimerDB.start_timer(Stopwatch.TimerDB) 22 | 23 | TimerDB.notify() 24 | {:noreply, socket} 25 | end 26 | 27 | def handle_event("stop", _value, socket) do 28 | TimerDB.stop_timer(Stopwatch.TimerDB) 29 | TimerDB.notify() 30 | {:noreply, socket} 31 | end 32 | 33 | def handle_event("reset", _value, socket) do 34 | TimerDB.reset_timer(Stopwatch.TimerDB) 35 | TimerDB.notify() 36 | {:noreply, socket} 37 | end 38 | 39 | def handle_info(:timer_updated, socket) do 40 | {timer_status, start, stop} = TimerDB.get_timer_state(Stopwatch.TimerDB) 41 | socket = assign(socket, timer_status: timer_status, start: start, stop: stop) 42 | 43 | {:noreply, 44 | push_event(socket, "timerUpdated", %{timer_status: timer_status, start: start, stop: stop})} 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/stopwatch_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule StopwatchWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Phoenix Metrics 25 | summary("phoenix.endpoint.stop.duration", 26 | unit: {:native, :millisecond} 27 | ), 28 | summary("phoenix.router_dispatch.stop.duration", 29 | tags: [:route], 30 | unit: {:native, :millisecond} 31 | ), 32 | 33 | # VM Metrics 34 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 35 | summary("vm.total_run_queue_lengths.total"), 36 | summary("vm.total_run_queue_lengths.cpu"), 37 | summary("vm.total_run_queue_lengths.io") 38 | ] 39 | end 40 | 41 | defp periodic_measurements do 42 | [ 43 | # A module, function and arguments to be invoked periodically. 44 | # This function must call :telemetry.execute/3 and a metric must be added above. 45 | # {StopwatchWeb, :count_users, []} 46 | ] 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/stopwatch/timer_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Stopwatch.TimerServer do 2 | use GenServer 3 | alias Phoenix.PubSub 4 | 5 | def start_link(opts) do 6 | GenServer.start_link(__MODULE__, :ok, opts) 7 | end 8 | 9 | def start_timer(server) do 10 | GenServer.call(server, :start) 11 | end 12 | 13 | def stop_timer(server) do 14 | GenServer.call(server, :stop) 15 | end 16 | 17 | def get_timer_state(server) do 18 | GenServer.call(server, :state) 19 | end 20 | 21 | def reset(server) do 22 | GenServer.call(server, :reset) 23 | end 24 | 25 | @impl true 26 | def init(:ok) do 27 | {:ok, {:stopped, ~T[00:00:00]}} 28 | end 29 | 30 | @impl true 31 | def handle_call(:start, _from, {_status, time}) do 32 | Process.send_after(self(), :tick, 1000) 33 | {:reply, :running, {:running, time}} 34 | end 35 | 36 | @impl true 37 | def handle_call(:stop, _from, {_status, time}) do 38 | {:reply, :stopped, {:stopped, time}} 39 | end 40 | 41 | @impl true 42 | def handle_call(:state, _from, stopwatch) do 43 | {:reply, stopwatch, stopwatch} 44 | end 45 | 46 | @impl true 47 | def handle_call(:reset, _from, _stopwatch) do 48 | {:reply, :reset, {:stopped, ~T[00:00:00]}} 49 | end 50 | 51 | @impl true 52 | def handle_info(:tick, {status, time} = stopwatch) do 53 | if status == :running do 54 | Process.send_after(self(), :tick, 1000) 55 | notify() 56 | {:noreply, {status, Time.add(time, 1, :second)}} 57 | else 58 | {:noreply, stopwatch} 59 | end 60 | end 61 | 62 | def subscribe() do 63 | PubSub.subscribe(Stopwatch.PubSub, "liveview_stopwatch") 64 | end 65 | 66 | def notify() do 67 | PubSub.broadcast(Stopwatch.PubSub, "liveview_stopwatch", :timer_updated) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For production, don't forget to configure the url host 4 | # to something meaningful, Phoenix uses this information 5 | # when generating URLs. 6 | # 7 | # Note we also include the path to a cache manifest 8 | # containing the digested version of static files. This 9 | # manifest is generated by the `mix phx.digest` task, 10 | # which you should run after static files are built and 11 | # before starting your production server. 12 | config :stopwatch, StopwatchWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" 13 | 14 | # Do not print debug messages in production 15 | config :logger, level: :info 16 | 17 | # ## SSL Support 18 | # 19 | # To get SSL working, you will need to add the `https` key 20 | # to the previous section and set your `:url` port to 443: 21 | # 22 | # config :stopwatch, StopwatchWeb.Endpoint, 23 | # ..., 24 | # url: [host: "example.com", port: 443], 25 | # https: [ 26 | # ..., 27 | # port: 443, 28 | # cipher_suite: :strong, 29 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 30 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 31 | # ] 32 | # 33 | # The `cipher_suite` is set to `:strong` to support only the 34 | # latest and more secure SSL ciphers. This means old browsers 35 | # and clients may not be supported. You can set it to 36 | # `:compatible` for wider support. 37 | # 38 | # `:keyfile` and `:certfile` expect an absolute path to the key 39 | # and cert in disk or a relative path inside priv, for example 40 | # "priv/ssl/server.key". For all supported SSL configuration 41 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 42 | # 43 | # We also recommend setting `force_ssl` in your endpoint, ensuring 44 | # no data is ever sent via http, always redirecting to https: 45 | # 46 | # config :stopwatch, StopwatchWeb.Endpoint, 47 | # force_ssl: [hsts: true] 48 | # 49 | # Check `Plug.SSL` for all available options in `force_ssl`. 50 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config/runtime.exs is executed for all environments, including 4 | # during releases. It is executed after compilation and before the 5 | # system starts, so it is typically used to load production configuration 6 | # and secrets from environment variables or elsewhere. Do not define 7 | # any compile-time configuration in here, as it won't be applied. 8 | # The block below contains prod specific runtime configuration. 9 | 10 | # ## Using releases 11 | # 12 | # If you use `mix release`, you need to explicitly enable the server 13 | # by passing the PHX_SERVER=true when you start it: 14 | # 15 | # PHX_SERVER=true bin/stopwatch start 16 | # 17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` 18 | # script that automatically sets the env var above. 19 | if System.get_env("PHX_SERVER") do 20 | config :stopwatch, StopwatchWeb.Endpoint, server: true 21 | end 22 | 23 | if config_env() == :prod do 24 | # The secret key base is used to sign/encrypt cookies and other secrets. 25 | # A default value is used in config/dev.exs and config/test.exs but you 26 | # want to use a different value for prod and you most likely don't want 27 | # to check this value into version control, so we use an environment 28 | # variable instead. 29 | secret_key_base = 30 | System.get_env("SECRET_KEY_BASE") || 31 | raise """ 32 | environment variable SECRET_KEY_BASE is missing. 33 | You can generate one by calling: mix phx.gen.secret 34 | """ 35 | 36 | host = System.get_env("PHX_HOST") || "example.com" 37 | port = String.to_integer(System.get_env("PORT") || "4000") 38 | 39 | config :stopwatch, StopwatchWeb.Endpoint, 40 | url: [host: host, port: 443, scheme: "https"], 41 | http: [ 42 | # Enable IPv6 and bind on all interfaces. 43 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 44 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html 45 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 46 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 47 | port: port 48 | ], 49 | secret_key_base: secret_key_base 50 | end 51 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we use it 8 | # with esbuild to bundle .js and .css sources. 9 | config :stopwatch, StopwatchWeb.Endpoint, 10 | # Binding to loopback ipv4 address prevents access from other machines. 11 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 12 | http: [ip: {127, 0, 0, 1}, port: 4000], 13 | check_origin: false, 14 | code_reloader: true, 15 | debug_errors: true, 16 | secret_key_base: "HbMpQwVovTusrMkXP4odvg/ZcoK1IJgn0CX9N02ItCnt69on8aLlLB5ykZhwtF7Z", 17 | watchers: [ 18 | # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args) 19 | esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]} 20 | ] 21 | 22 | # ## SSL Support 23 | # 24 | # In order to use HTTPS in development, a self-signed 25 | # certificate can be generated by running the following 26 | # Mix task: 27 | # 28 | # mix phx.gen.cert 29 | # 30 | # Note that this task requires Erlang/OTP 20 or later. 31 | # Run `mix help phx.gen.cert` for more information. 32 | # 33 | # The `http:` config above can be replaced with: 34 | # 35 | # https: [ 36 | # port: 4001, 37 | # cipher_suite: :strong, 38 | # keyfile: "priv/cert/selfsigned_key.pem", 39 | # certfile: "priv/cert/selfsigned.pem" 40 | # ], 41 | # 42 | # If desired, both `http:` and `https:` keys can be 43 | # configured to run both http and https servers on 44 | # different ports. 45 | 46 | # Watch static and templates for browser reloading. 47 | config :stopwatch, StopwatchWeb.Endpoint, 48 | live_reload: [ 49 | patterns: [ 50 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 51 | ~r"lib/stopwatch_web/(live|views)/.*(ex)$", 52 | ~r"lib/stopwatch_web/templates/.*(eex)$" 53 | ] 54 | ] 55 | 56 | # Do not include metadata nor timestamps in development logs 57 | config :logger, :console, format: "[$level] $message\n" 58 | 59 | # Set a higher stacktrace during development. Avoid configuring such 60 | # in production as building large stacktraces may be expensive. 61 | config :phoenix, :stacktrace_depth, 20 62 | 63 | # Initialize plugs at runtime for faster development compilation 64 | config :phoenix, :plug_init_mode, :runtime 65 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Stopwatch.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :stopwatch, 7 | version: "0.1.0", 8 | elixir: "~> 1.13", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | compilers: Mix.compilers(), 11 | start_permanent: Mix.env() == :prod, 12 | aliases: aliases(), 13 | deps: deps(), 14 | test_coverage: [tool: ExCoveralls], 15 | preferred_cli_env: [ 16 | c: :test, 17 | coveralls: :test, 18 | "coveralls.detail": :test, 19 | "coveralls.post": :test, 20 | "coveralls.html": :test, 21 | "coveralls.json": :test 22 | ], 23 | ] 24 | end 25 | 26 | # Configuration for the OTP application. 27 | # 28 | # Type `mix help compile.app` for more information. 29 | def application do 30 | [ 31 | mod: {Stopwatch.Application, []}, 32 | extra_applications: [:logger, :runtime_tools] 33 | ] 34 | end 35 | 36 | # Specifies which paths to compile per environment. 37 | defp elixirc_paths(:test), do: ["lib", "test/support"] 38 | defp elixirc_paths(_), do: ["lib"] 39 | 40 | # Specifies your project dependencies. 41 | # 42 | # Type `mix help deps` for examples and options. 43 | defp deps do 44 | [ 45 | {:phoenix, "~> 1.8.1"}, 46 | {:phoenix_html, "~> 4.0"}, 47 | {:phoenix_html_helpers, "~> 1.0"}, 48 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 49 | {:phoenix_live_view, "~> 1.1.2"}, 50 | {:phoenix_view, "~> 2.0"}, 51 | {:floki, ">= 0.30.0", only: :test}, 52 | {:esbuild, "~> 0.4", runtime: Mix.env() == :dev}, 53 | {:telemetry_metrics, "~> 1.0"}, 54 | {:telemetry_poller, "~> 1.0"}, 55 | {:jason, "~> 1.2"}, 56 | {:plug_cowboy, "~> 2.5"}, 57 | 58 | # Check test coverage: hex.pm/packages/excoveralls 59 | {:excoveralls, "~> 0.18.0", only: :test}, 60 | {:lazy_html, ">= 0.1.0", only: :test} 61 | ] 62 | end 63 | 64 | # Aliases are shortcuts or tasks specific to the current project. 65 | # For example, to install project dependencies and perform other setup tasks, run: 66 | # 67 | # $ mix setup 68 | # 69 | # See the documentation for `Mix` for more info on aliases. 70 | defp aliases do 71 | [ 72 | c: ["coveralls.html"], 73 | setup: ["deps.get"], 74 | "assets.deploy": ["esbuild default --minify", "phx.digest"] 75 | ] 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | /* This file is for your main application CSS */ 2 | @import "./phoenix.css"; 3 | 4 | /* Alerts and form errors used by phx.new */ 5 | .alert { 6 | padding: 15px; 7 | margin-bottom: 20px; 8 | border: 1px solid transparent; 9 | border-radius: 4px; 10 | } 11 | .alert-info { 12 | color: #31708f; 13 | background-color: #d9edf7; 14 | border-color: #bce8f1; 15 | } 16 | .alert-warning { 17 | color: #8a6d3b; 18 | background-color: #fcf8e3; 19 | border-color: #faebcc; 20 | } 21 | .alert-danger { 22 | color: #a94442; 23 | background-color: #f2dede; 24 | border-color: #ebccd1; 25 | } 26 | .alert p { 27 | margin-bottom: 0; 28 | } 29 | .alert:empty { 30 | display: none; 31 | } 32 | .invalid-feedback { 33 | color: #a94442; 34 | display: block; 35 | margin: -1rem 0 2rem; 36 | } 37 | 38 | /* LiveView specific classes for your customization */ 39 | .phx-no-feedback.invalid-feedback, 40 | .phx-no-feedback .invalid-feedback { 41 | display: none; 42 | } 43 | 44 | .phx-click-loading { 45 | opacity: 0.5; 46 | transition: opacity 1s ease-out; 47 | } 48 | 49 | .phx-loading{ 50 | cursor: wait; 51 | } 52 | 53 | .phx-modal { 54 | opacity: 1!important; 55 | position: fixed; 56 | z-index: 1; 57 | left: 0; 58 | top: 0; 59 | width: 100%; 60 | height: 100%; 61 | overflow: auto; 62 | background-color: rgba(0,0,0,0.4); 63 | } 64 | 65 | .phx-modal-content { 66 | background-color: #fefefe; 67 | margin: 15vh auto; 68 | padding: 20px; 69 | border: 1px solid #888; 70 | width: 80%; 71 | } 72 | 73 | .phx-modal-close { 74 | color: #aaa; 75 | float: right; 76 | font-size: 28px; 77 | font-weight: bold; 78 | } 79 | 80 | .phx-modal-close:hover, 81 | .phx-modal-close:focus { 82 | color: black; 83 | text-decoration: none; 84 | cursor: pointer; 85 | } 86 | 87 | .fade-in-scale { 88 | animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys; 89 | } 90 | 91 | .fade-out-scale { 92 | animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys; 93 | } 94 | 95 | .fade-in { 96 | animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys; 97 | } 98 | .fade-out { 99 | animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys; 100 | } 101 | 102 | @keyframes fade-in-scale-keys{ 103 | 0% { scale: 0.95; opacity: 0; } 104 | 100% { scale: 1.0; opacity: 1; } 105 | } 106 | 107 | @keyframes fade-out-scale-keys{ 108 | 0% { scale: 1.0; opacity: 1; } 109 | 100% { scale: 0.95; opacity: 0; } 110 | } 111 | 112 | @keyframes fade-in-keys{ 113 | 0% { opacity: 0; } 114 | 100% { opacity: 1; } 115 | } 116 | 117 | @keyframes fade-out-keys{ 118 | 0% { opacity: 1; } 119 | 100% { opacity: 0; } 120 | } 121 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian instead of 2 | # Alpine to avoid DNS resolution issues in production. 3 | # 4 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu 5 | # https://hub.docker.com/_/ubuntu?tab=tags 6 | # 7 | # 8 | # This file is based on these images: 9 | # 10 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image 11 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20210902-slim - for the release image 12 | # - https://pkgs.org/ - resource for finding needed packages 13 | # - Ex: hexpm/elixir:1.13.3-erlang-24.3.1-debian-bullseye-20210902-slim 14 | # 15 | ARG ELIXIR_VERSION=1.13.3 16 | ARG OTP_VERSION=24.3.1 17 | ARG DEBIAN_VERSION=bullseye-20210902-slim 18 | 19 | ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" 20 | ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" 21 | 22 | FROM ${BUILDER_IMAGE} as builder 23 | 24 | # install build dependencies 25 | RUN apt-get update -y && apt-get install -y build-essential git \ 26 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 27 | 28 | # prepare build dir 29 | WORKDIR /app 30 | 31 | # install hex + rebar 32 | RUN mix local.hex --force && \ 33 | mix local.rebar --force 34 | 35 | # set build ENV 36 | ENV MIX_ENV="prod" 37 | 38 | # install mix dependencies 39 | COPY mix.exs mix.lock ./ 40 | RUN mix deps.get --only $MIX_ENV 41 | RUN mkdir config 42 | 43 | # copy compile-time config files before we compile dependencies 44 | # to ensure any relevant config change will trigger the dependencies 45 | # to be re-compiled. 46 | COPY config/config.exs config/${MIX_ENV}.exs config/ 47 | RUN mix deps.compile 48 | 49 | COPY priv priv 50 | 51 | COPY lib lib 52 | 53 | COPY assets assets 54 | 55 | # compile assets 56 | RUN mix assets.deploy 57 | 58 | # Compile the release 59 | RUN mix compile 60 | 61 | # Changes to config/runtime.exs don't require recompiling the code 62 | COPY config/runtime.exs config/ 63 | 64 | COPY rel rel 65 | RUN mix release 66 | 67 | # start a new build stage so that the final image will only contain 68 | # the compiled release and other runtime necessities 69 | FROM ${RUNNER_IMAGE} 70 | 71 | RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales \ 72 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 73 | 74 | # Set the locale 75 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen 76 | 77 | ENV LANG en_US.UTF-8 78 | ENV LANGUAGE en_US:en 79 | ENV LC_ALL en_US.UTF-8 80 | 81 | WORKDIR "/app" 82 | RUN chown nobody /app 83 | 84 | # set runner ENV 85 | ENV MIX_ENV="prod" 86 | 87 | # Only copy the final release from the build stage 88 | COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/stopwatch ./ 89 | 90 | USER nobody 91 | 92 | CMD ["/app/bin/server"] 93 | # Appended by flyctl 94 | ENV ECTO_IPV6 true 95 | ENV ERL_AFLAGS "-proto_dist inet6_tcp" 96 | -------------------------------------------------------------------------------- /lib/stopwatch_web.ex: -------------------------------------------------------------------------------- 1 | defmodule StopwatchWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, views, channels and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use StopwatchWeb, :controller 9 | use StopwatchWeb, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define any helper function in modules 17 | and import those modules here. 18 | """ 19 | 20 | def controller do 21 | quote do 22 | use Phoenix.Controller, namespace: StopwatchWeb 23 | 24 | import Plug.Conn 25 | alias StopwatchWeb.Router.Helpers, as: Routes 26 | end 27 | end 28 | 29 | def view do 30 | quote do 31 | use Phoenix.View, 32 | root: "lib/stopwatch_web/templates", 33 | namespace: StopwatchWeb 34 | 35 | # Import convenience functions from controllers 36 | import Phoenix.Controller, 37 | only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] 38 | 39 | # Include shared imports and aliases for views 40 | unquote(view_helpers()) 41 | end 42 | end 43 | 44 | def live_view do 45 | quote do 46 | use Phoenix.LiveView, 47 | layout: {StopwatchWeb.LayoutView, :live} 48 | 49 | unquote(view_helpers()) 50 | end 51 | end 52 | 53 | def live_component do 54 | quote do 55 | use Phoenix.LiveComponent 56 | 57 | unquote(view_helpers()) 58 | end 59 | end 60 | 61 | def component do 62 | quote do 63 | use Phoenix.Component 64 | 65 | unquote(view_helpers()) 66 | end 67 | end 68 | 69 | def router do 70 | quote do 71 | use Phoenix.Router 72 | 73 | import Plug.Conn 74 | import Phoenix.Controller 75 | import Phoenix.LiveView.Router 76 | end 77 | end 78 | 79 | def channel do 80 | quote do 81 | use Phoenix.Channel 82 | end 83 | end 84 | 85 | defp view_helpers do 86 | quote do 87 | # Use all HTML functionality (forms, tags, etc) 88 | import Phoenix.HTML 89 | use PhoenixHTMLHelpers 90 | 91 | # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) 92 | import Phoenix.LiveView.Helpers 93 | import Phoenix.Component 94 | 95 | # Import basic rendering functionality (render, render_layout, etc) 96 | import Phoenix.View 97 | 98 | import StopwatchWeb.ErrorHelpers 99 | alias StopwatchWeb.Router.Helpers, as: Routes 100 | end 101 | end 102 | 103 | @doc """ 104 | When used, dispatch to the appropriate controller/view/etc. 105 | """ 106 | defmacro __using__(which) when is_atom(which) do 107 | apply(__MODULE__, which, []) 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // We import the CSS which is extracted to its own file by esbuild. 2 | // Remove this line if you add a your own CSS build pipeline (e.g postcss). 3 | import "../css/app.css" 4 | 5 | // If you want to use Phoenix channels, run `mix help phx.gen.channel` 6 | // to get started and then uncomment the line below. 7 | // import "./user_socket.js" 8 | 9 | // You can include dependencies in two ways. 10 | // 11 | // The simplest option is to put them in assets/vendor and 12 | // import them using relative paths: 13 | // 14 | // import "../vendor/some-package.js" 15 | // 16 | // Alternatively, you can `npm install some-package --prefix assets` and import 17 | // them using a path starting with the package name: 18 | // 19 | // import "some-package" 20 | // 21 | 22 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 23 | import "phoenix_html" 24 | // Establish Phoenix Socket and LiveView configuration. 25 | import {Socket} from "phoenix" 26 | import {LiveSocket} from "phoenix_live_view" 27 | import topbar from "../vendor/topbar" 28 | 29 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 30 | let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) 31 | 32 | // Show progress bar on live navigation and form submits 33 | topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) 34 | window.addEventListener("phx:page-loading-start", info => topbar.show()) 35 | window.addEventListener("phx:page-loading-stop", info => topbar.hide()) 36 | timer = document.getElementById("timer") 37 | T = {ticking: false} 38 | window.addEventListener("phx:timerUpdated", e => { 39 | if (e.detail.timer_status == "running" && !T.ticking) { 40 | T.ticking = true 41 | T.timerInterval = setInterval(function() { 42 | text = timer_text(new Date(e.detail.start), Date.now()) 43 | timer.textContent = text 44 | }, 1000); 45 | } 46 | 47 | if (e.detail.timer_status == "stopped") { 48 | clearInterval(T.timerInterval) 49 | T.ticking = false 50 | text = timer_text(new Date(e.detail.start), new Date(e.detail.stop)) 51 | timer.textContent = text 52 | } 53 | }) 54 | 55 | function leftPad(val) { 56 | return val < 10 ? '0' + String(val) : val; 57 | } 58 | 59 | function timer_text(start, current) { 60 | let h="00", m="00", s="00"; 61 | const diff = current - start; 62 | // seconds 63 | if(diff > 1000) { 64 | s = Math.floor(diff / 1000); 65 | s = s > 60 ? s % 60 : s; 66 | s = leftPad(s); 67 | } 68 | // minutes 69 | if(diff > 60000) { 70 | m = Math.floor(diff/60000); 71 | m = m > 60 ? m % 60 : leftPad(m); 72 | } 73 | // hours 74 | if(diff > 3600000) { 75 | h = Math.floor(diff/3600000); 76 | h = leftPad(h) 77 | } 78 | 79 | return h + ':' + m + ':' + s; 80 | } 81 | // connect if there are any LiveViews on the page 82 | liveSocket.connect() 83 | 84 | // expose liveSocket on window for web console debug logs and latency simulation: 85 | // >> liveSocket.enableDebug() 86 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 87 | // >> liveSocket.disableLatencySim() 88 | window.liveSocket = liveSocket 89 | 90 | -------------------------------------------------------------------------------- /assets/vendor/topbar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license MIT 3 | * topbar 1.0.0, 2021-01-06 4 | * https://buunguyen.github.io/topbar 5 | * Copyright (c) 2021 Buu Nguyen 6 | */ 7 | (function (window, document) { 8 | "use strict"; 9 | 10 | // https://gist.github.com/paulirish/1579671 11 | (function () { 12 | var lastTime = 0; 13 | var vendors = ["ms", "moz", "webkit", "o"]; 14 | for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { 15 | window.requestAnimationFrame = 16 | window[vendors[x] + "RequestAnimationFrame"]; 17 | window.cancelAnimationFrame = 18 | window[vendors[x] + "CancelAnimationFrame"] || 19 | window[vendors[x] + "CancelRequestAnimationFrame"]; 20 | } 21 | if (!window.requestAnimationFrame) 22 | window.requestAnimationFrame = function (callback, element) { 23 | var currTime = new Date().getTime(); 24 | var timeToCall = Math.max(0, 16 - (currTime - lastTime)); 25 | var id = window.setTimeout(function () { 26 | callback(currTime + timeToCall); 27 | }, timeToCall); 28 | lastTime = currTime + timeToCall; 29 | return id; 30 | }; 31 | if (!window.cancelAnimationFrame) 32 | window.cancelAnimationFrame = function (id) { 33 | clearTimeout(id); 34 | }; 35 | })(); 36 | 37 | var canvas, 38 | progressTimerId, 39 | fadeTimerId, 40 | currentProgress, 41 | showing, 42 | addEvent = function (elem, type, handler) { 43 | if (elem.addEventListener) elem.addEventListener(type, handler, false); 44 | else if (elem.attachEvent) elem.attachEvent("on" + type, handler); 45 | else elem["on" + type] = handler; 46 | }, 47 | options = { 48 | autoRun: true, 49 | barThickness: 3, 50 | barColors: { 51 | 0: "rgba(26, 188, 156, .9)", 52 | ".25": "rgba(52, 152, 219, .9)", 53 | ".50": "rgba(241, 196, 15, .9)", 54 | ".75": "rgba(230, 126, 34, .9)", 55 | "1.0": "rgba(211, 84, 0, .9)", 56 | }, 57 | shadowBlur: 10, 58 | shadowColor: "rgba(0, 0, 0, .6)", 59 | className: null, 60 | }, 61 | repaint = function () { 62 | canvas.width = window.innerWidth; 63 | canvas.height = options.barThickness * 5; // need space for shadow 64 | 65 | var ctx = canvas.getContext("2d"); 66 | ctx.shadowBlur = options.shadowBlur; 67 | ctx.shadowColor = options.shadowColor; 68 | 69 | var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); 70 | for (var stop in options.barColors) 71 | lineGradient.addColorStop(stop, options.barColors[stop]); 72 | ctx.lineWidth = options.barThickness; 73 | ctx.beginPath(); 74 | ctx.moveTo(0, options.barThickness / 2); 75 | ctx.lineTo( 76 | Math.ceil(currentProgress * canvas.width), 77 | options.barThickness / 2 78 | ); 79 | ctx.strokeStyle = lineGradient; 80 | ctx.stroke(); 81 | }, 82 | createCanvas = function () { 83 | canvas = document.createElement("canvas"); 84 | var style = canvas.style; 85 | style.position = "fixed"; 86 | style.top = style.left = style.right = style.margin = style.padding = 0; 87 | style.zIndex = 100001; 88 | style.display = "none"; 89 | if (options.className) canvas.classList.add(options.className); 90 | document.body.appendChild(canvas); 91 | addEvent(window, "resize", repaint); 92 | }, 93 | topbar = { 94 | config: function (opts) { 95 | for (var key in opts) 96 | if (options.hasOwnProperty(key)) options[key] = opts[key]; 97 | }, 98 | show: function () { 99 | if (showing) return; 100 | showing = true; 101 | if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); 102 | if (!canvas) createCanvas(); 103 | canvas.style.opacity = 1; 104 | canvas.style.display = "block"; 105 | topbar.progress(0); 106 | if (options.autoRun) { 107 | (function loop() { 108 | progressTimerId = window.requestAnimationFrame(loop); 109 | topbar.progress( 110 | "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) 111 | ); 112 | })(); 113 | } 114 | }, 115 | progress: function (to) { 116 | if (typeof to === "undefined") return currentProgress; 117 | if (typeof to === "string") { 118 | to = 119 | (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 120 | ? currentProgress 121 | : 0) + parseFloat(to); 122 | } 123 | currentProgress = to > 1 ? 1 : to; 124 | repaint(); 125 | return currentProgress; 126 | }, 127 | hide: function () { 128 | if (!showing) return; 129 | showing = false; 130 | if (progressTimerId != null) { 131 | window.cancelAnimationFrame(progressTimerId); 132 | progressTimerId = null; 133 | } 134 | (function loop() { 135 | if (topbar.progress("+.1") >= 1) { 136 | canvas.style.opacity -= 0.05; 137 | if (canvas.style.opacity <= 0.05) { 138 | canvas.style.display = "none"; 139 | fadeTimerId = null; 140 | return; 141 | } 142 | } 143 | fadeTimerId = window.requestAnimationFrame(loop); 144 | })(); 145 | }, 146 | }; 147 | 148 | if (typeof module === "object" && typeof module.exports === "object") { 149 | module.exports = topbar; 150 | } else if (typeof define === "function" && define.amd) { 151 | define(function () { 152 | return topbar; 153 | }); 154 | } else { 155 | this.topbar = topbar; 156 | } 157 | }.call(this, window, document)); 158 | -------------------------------------------------------------------------------- /assets/css/phoenix.css: -------------------------------------------------------------------------------- 1 | /* Includes some default style for the starter application. 2 | * This can be safely deleted to start fresh. 3 | */ 4 | 5 | /* Milligram v1.4.1 https://milligram.github.io 6 | * Copyright (c) 2020 CJ Patoilo Licensed under the MIT license 7 | */ 8 | 9 | *,*:after,*:before{box-sizing:inherit}html{box-sizing:border-box;font-size:62.5%}body{color:#000000;font-family:'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;font-size:1.6em;font-weight:300;letter-spacing:.01em;line-height:1.6}blockquote{border-left:0.3rem solid #d1d1d1;margin-left:0;margin-right:0;padding:1rem 1.5rem}blockquote *:last-child{margin-bottom:0}.button,button,input[type='button'],input[type='reset'],input[type='submit']{background-color:#0069d9;border:0.1rem solid #0069d9;border-radius:.4rem;color:#fff;cursor:pointer;display:inline-block;font-size:1.1rem;font-weight:700;height:3.8rem;letter-spacing:.1rem;line-height:3.8rem;padding:0 3.0rem;text-align:center;text-decoration:none;text-transform:uppercase;white-space:nowrap}.button:focus,.button:hover,button:focus,button:hover,input[type='button']:focus,input[type='button']:hover,input[type='reset']:focus,input[type='reset']:hover,input[type='submit']:focus,input[type='submit']:hover{background-color:#606c76;border-color:#606c76;color:#fff;outline:0}.button[disabled],button[disabled],input[type='button'][disabled],input[type='reset'][disabled],input[type='submit'][disabled]{cursor:default;opacity:.5}.button[disabled]:focus,.button[disabled]:hover,button[disabled]:focus,button[disabled]:hover,input[type='button'][disabled]:focus,input[type='button'][disabled]:hover,input[type='reset'][disabled]:focus,input[type='reset'][disabled]:hover,input[type='submit'][disabled]:focus,input[type='submit'][disabled]:hover{background-color:#0069d9;border-color:#0069d9}.button.button-outline,button.button-outline,input[type='button'].button-outline,input[type='reset'].button-outline,input[type='submit'].button-outline{background-color:transparent;color:#0069d9}.button.button-outline:focus,.button.button-outline:hover,button.button-outline:focus,button.button-outline:hover,input[type='button'].button-outline:focus,input[type='button'].button-outline:hover,input[type='reset'].button-outline:focus,input[type='reset'].button-outline:hover,input[type='submit'].button-outline:focus,input[type='submit'].button-outline:hover{background-color:transparent;border-color:#606c76;color:#606c76}.button.button-outline[disabled]:focus,.button.button-outline[disabled]:hover,button.button-outline[disabled]:focus,button.button-outline[disabled]:hover,input[type='button'].button-outline[disabled]:focus,input[type='button'].button-outline[disabled]:hover,input[type='reset'].button-outline[disabled]:focus,input[type='reset'].button-outline[disabled]:hover,input[type='submit'].button-outline[disabled]:focus,input[type='submit'].button-outline[disabled]:hover{border-color:inherit;color:#0069d9}.button.button-clear,button.button-clear,input[type='button'].button-clear,input[type='reset'].button-clear,input[type='submit'].button-clear{background-color:transparent;border-color:transparent;color:#0069d9}.button.button-clear:focus,.button.button-clear:hover,button.button-clear:focus,button.button-clear:hover,input[type='button'].button-clear:focus,input[type='button'].button-clear:hover,input[type='reset'].button-clear:focus,input[type='reset'].button-clear:hover,input[type='submit'].button-clear:focus,input[type='submit'].button-clear:hover{background-color:transparent;border-color:transparent;color:#606c76}.button.button-clear[disabled]:focus,.button.button-clear[disabled]:hover,button.button-clear[disabled]:focus,button.button-clear[disabled]:hover,input[type='button'].button-clear[disabled]:focus,input[type='button'].button-clear[disabled]:hover,input[type='reset'].button-clear[disabled]:focus,input[type='reset'].button-clear[disabled]:hover,input[type='submit'].button-clear[disabled]:focus,input[type='submit'].button-clear[disabled]:hover{color:#0069d9}code{background:#f4f5f6;border-radius:.4rem;font-size:86%;margin:0 .2rem;padding:.2rem .5rem;white-space:nowrap}pre{background:#f4f5f6;border-left:0.3rem solid #0069d9;overflow-y:hidden}pre>code{border-radius:0;display:block;padding:1rem 1.5rem;white-space:pre}hr{border:0;border-top:0.1rem solid #f4f5f6;margin:3.0rem 0}input[type='color'],input[type='date'],input[type='datetime'],input[type='datetime-local'],input[type='email'],input[type='month'],input[type='number'],input[type='password'],input[type='search'],input[type='tel'],input[type='text'],input[type='url'],input[type='week'],input:not([type]),textarea,select{-webkit-appearance:none;background-color:transparent;border:0.1rem solid #d1d1d1;border-radius:.4rem;box-shadow:none;box-sizing:inherit;height:3.8rem;padding:.6rem 1.0rem .7rem;width:100%}input[type='color']:focus,input[type='date']:focus,input[type='datetime']:focus,input[type='datetime-local']:focus,input[type='email']:focus,input[type='month']:focus,input[type='number']:focus,input[type='password']:focus,input[type='search']:focus,input[type='tel']:focus,input[type='text']:focus,input[type='url']:focus,input[type='week']:focus,input:not([type]):focus,textarea:focus,select:focus{border-color:#0069d9;outline:0}select{background:url('data:image/svg+xml;utf8,') center right no-repeat;padding-right:3.0rem}select:focus{background-image:url('data:image/svg+xml;utf8,')}select[multiple]{background:none;height:auto}textarea{min-height:6.5rem}label,legend{display:block;font-size:1.6rem;font-weight:700;margin-bottom:.5rem}fieldset{border-width:0;padding:0}input[type='checkbox'],input[type='radio']{display:inline}.label-inline{display:inline-block;font-weight:normal;margin-left:.5rem}.container{margin:0 auto;max-width:112.0rem;padding:0 2.0rem;position:relative;width:100%}.row{display:flex;flex-direction:column;padding:0;width:100%}.row.row-no-padding{padding:0}.row.row-no-padding>.column{padding:0}.row.row-wrap{flex-wrap:wrap}.row.row-top{align-items:flex-start}.row.row-bottom{align-items:flex-end}.row.row-center{align-items:center}.row.row-stretch{align-items:stretch}.row.row-baseline{align-items:baseline}.row .column{display:block;flex:1 1 auto;margin-left:0;max-width:100%;width:100%}.row .column.column-offset-10{margin-left:10%}.row .column.column-offset-20{margin-left:20%}.row .column.column-offset-25{margin-left:25%}.row .column.column-offset-33,.row .column.column-offset-34{margin-left:33.3333%}.row .column.column-offset-40{margin-left:40%}.row .column.column-offset-50{margin-left:50%}.row .column.column-offset-60{margin-left:60%}.row .column.column-offset-66,.row .column.column-offset-67{margin-left:66.6666%}.row .column.column-offset-75{margin-left:75%}.row .column.column-offset-80{margin-left:80%}.row .column.column-offset-90{margin-left:90%}.row .column.column-10{flex:0 0 10%;max-width:10%}.row .column.column-20{flex:0 0 20%;max-width:20%}.row .column.column-25{flex:0 0 25%;max-width:25%}.row .column.column-33,.row .column.column-34{flex:0 0 33.3333%;max-width:33.3333%}.row .column.column-40{flex:0 0 40%;max-width:40%}.row .column.column-50{flex:0 0 50%;max-width:50%}.row .column.column-60{flex:0 0 60%;max-width:60%}.row .column.column-66,.row .column.column-67{flex:0 0 66.6666%;max-width:66.6666%}.row .column.column-75{flex:0 0 75%;max-width:75%}.row .column.column-80{flex:0 0 80%;max-width:80%}.row .column.column-90{flex:0 0 90%;max-width:90%}.row .column .column-top{align-self:flex-start}.row .column .column-bottom{align-self:flex-end}.row .column .column-center{align-self:center}@media (min-width: 40rem){.row{flex-direction:row;margin-left:-1.0rem;width:calc(100% + 2.0rem)}.row .column{margin-bottom:inherit;padding:0 1.0rem}}a{color:#0069d9;text-decoration:none}a:focus,a:hover{color:#606c76}dl,ol,ul{list-style:none;margin-top:0;padding-left:0}dl dl,dl ol,dl ul,ol dl,ol ol,ol ul,ul dl,ul ol,ul ul{font-size:90%;margin:1.5rem 0 1.5rem 3.0rem}ol{list-style:decimal inside}ul{list-style:circle inside}.button,button,dd,dt,li{margin-bottom:1.0rem}fieldset,input,select,textarea{margin-bottom:1.5rem}blockquote,dl,figure,form,ol,p,pre,table,ul{margin-bottom:2.5rem}table{border-spacing:0;display:block;overflow-x:auto;text-align:left;width:100%}td,th{border-bottom:0.1rem solid #e1e1e1;padding:1.2rem 1.5rem}td:first-child,th:first-child{padding-left:0}td:last-child,th:last-child{padding-right:0}@media (min-width: 40rem){table{display:table;overflow-x:initial}}b,strong{font-weight:bold}p{margin-top:0}h1,h2,h3,h4,h5,h6{font-weight:300;letter-spacing:-.1rem;margin-bottom:2.0rem;margin-top:0}h1{font-size:4.6rem;line-height:1.2}h2{font-size:3.6rem;line-height:1.25}h3{font-size:2.8rem;line-height:1.3}h4{font-size:2.2rem;letter-spacing:-.08rem;line-height:1.35}h5{font-size:1.8rem;letter-spacing:-.05rem;line-height:1.5}h6{font-size:1.6rem;letter-spacing:0;line-height:1.4}img{max-width:100%}.clearfix:after{clear:both;content:' ';display:table}.float-left{float:left}.float-right{float:right} 10 | 11 | /* General style */ 12 | h1{font-size: 3.6rem; line-height: 1.25} 13 | h2{font-size: 2.8rem; line-height: 1.3} 14 | h3{font-size: 2.2rem; letter-spacing: -.08rem; line-height: 1.35} 15 | h4{font-size: 1.8rem; letter-spacing: -.05rem; line-height: 1.5} 16 | h5{font-size: 1.6rem; letter-spacing: 0; line-height: 1.4} 17 | h6{font-size: 1.4rem; letter-spacing: 0; line-height: 1.2} 18 | pre{padding: 1em;} 19 | 20 | .container{ 21 | margin: 0 auto; 22 | max-width: 80.0rem; 23 | padding: 0 2.0rem; 24 | position: relative; 25 | width: 100% 26 | } 27 | select { 28 | width: auto; 29 | } 30 | 31 | /* Phoenix promo and logo */ 32 | .phx-hero { 33 | text-align: center; 34 | border-bottom: 1px solid #e3e3e3; 35 | background: #eee; 36 | border-radius: 6px; 37 | padding: 3em 3em 1em; 38 | margin-bottom: 3rem; 39 | font-weight: 200; 40 | font-size: 120%; 41 | } 42 | .phx-hero input { 43 | background: #ffffff; 44 | } 45 | .phx-logo { 46 | min-width: 300px; 47 | margin: 1rem; 48 | display: block; 49 | } 50 | .phx-logo img { 51 | width: auto; 52 | display: block; 53 | } 54 | 55 | /* Headers */ 56 | header { 57 | width: 100%; 58 | background: #fdfdfd; 59 | border-bottom: 1px solid #eaeaea; 60 | margin-bottom: 2rem; 61 | } 62 | header section { 63 | align-items: center; 64 | display: flex; 65 | flex-direction: column; 66 | justify-content: space-between; 67 | } 68 | header section :first-child { 69 | order: 2; 70 | } 71 | header section :last-child { 72 | order: 1; 73 | } 74 | header nav ul, 75 | header nav li { 76 | margin: 0; 77 | padding: 0; 78 | display: block; 79 | text-align: right; 80 | white-space: nowrap; 81 | } 82 | header nav ul { 83 | margin: 1rem; 84 | margin-top: 0; 85 | } 86 | header nav a { 87 | display: block; 88 | } 89 | 90 | @media (min-width: 40.0rem) { /* Small devices (landscape phones, 576px and up) */ 91 | header section { 92 | flex-direction: row; 93 | } 94 | header nav ul { 95 | margin: 1rem; 96 | } 97 | .phx-logo { 98 | flex-basis: 527px; 99 | margin: 2rem 1rem; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "castore": {:hex, :castore, "1.0.15", "8aa930c890fe18b6fe0a0cff27b27d0d4d231867897bd23ea772dee561f032a3", [:mix], [], "hexpm", "96ce4c69d7d5d7a0761420ef743e2f4096253931a3ba69e5ff8ef1844fe446d3"}, 3 | "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, 4 | "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, 5 | "cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"}, 6 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 7 | "cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"}, 8 | "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, 9 | "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, 10 | "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, 11 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 12 | "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, 13 | "floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"}, 14 | "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, 15 | "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, 16 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 17 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 18 | "lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"}, 19 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 20 | "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 21 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 22 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 23 | "phoenix": {:hex, :phoenix, "1.8.1", "865473a60a979551a4879db79fbfb4503e41cd809e77c85af79716578b6a456d", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "84d77d2b2e77c3c7e7527099bd01ef5c8560cd149c036d6b3a40745f11cd2fb2"}, 24 | "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, 25 | "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, 26 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.1", "05df733a09887a005ed0d69a7fc619d376aea2730bf64ce52ac51ce716cc1ef0", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "74273843d5a6e4fef0bbc17599f33e3ec63f08e69215623a0cd91eea4288e5a0"}, 27 | "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.16", "e42f95337b912a73a1c4ddb077af2eb13491712d7ab79b67e13de4237dfcac50", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f2a0093895b8ef4880af76d41de4a9cf7cff6c66ad130e15a70bdabc4d279feb"}, 28 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, 29 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 30 | "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, 31 | "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, 32 | "plug_cowboy": {:hex, :plug_cowboy, "2.7.4", "729c752d17cf364e2b8da5bdb34fb5804f56251e88bb602aff48ae0bd8673d11", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9b85632bd7012615bae0a5d70084deb1b25d2bcbb32cab82d1e9a1e023168aa3"}, 33 | "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, 34 | "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, 35 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 36 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 37 | "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, 38 | "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, 39 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 40 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 41 | "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # `Phoenix` `LiveView` _Stopwatch_ ⏱️ 4 | 5 | ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/dwyl/phoenix-liveview-stopwatch/ci.yml?label=build&style=flat-square&branch=main) 6 | [![codecov.io](https://img.shields.io/codecov/c/github/dwyl/phoenix-liveview-stopwatch/main.svg?style=flat-square)](https://codecov.io/github/dwyl/phoenix-liveview-stopwatch?branch=main) 7 | [![Hex.pm](https://img.shields.io/hexpm/v/phoenix?color=brightgreen&style=flat-square)](https://hex.pm/packages/phoenix) 8 | [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat-square)](https://github.com/dwyl/phoenix-liveview-stopwatch/issues) 9 | [![HitCount](https://hits.dwyl.com/dwyl/phoenix-liveview-stopwatch.svg)](https://hits.dwyl.com/dwyl/phoenix-liveview-stopwatch) 10 | 11 |
12 | 13 | - [`Phoenix` `LiveView` _Stopwatch_ ⏱️](#phoenix-liveview-stopwatch-️) 14 | - [Why? 💡](#why-) 15 | - [What? 🤷‍♀️](#what-️) 16 | - [How? 💻](#how-) 17 | - [Create a new "barebones" Phonenix application:](#create-a-new-barebones-phonenix-application) 18 | - [Create folders and files for `LiveView`:](#create-folders-and-files-for-liveview) 19 | - [Update `router.ex`](#update-routerex) 20 | - [Create `LiveView` logic](#create-liveview-logic) 21 | - [Update Root Template](#update-root-template) 22 | - [Create View](#create-view) 23 | - [Create Template](#create-template) 24 | - [Sync Stopwatch](#sync-stopwatch) 25 | - [`GenServer`](#genserver) 26 | - [`Liveview` + `JavaScript`](#liveview--javascript) 27 | - [What's _next_?](#whats-next) 28 | 29 |
30 | 31 | # Why? 💡 32 | 33 | We wanted to build the **simplest possible _shared_ stopwatch** 34 | as a self-contained 35 | [***experiment***](https://github.com/dwyl/technology-stack/issues/96) 36 | to test how easy complex/simple it would be 37 | before using this in our main 38 | [**`app`**](https://github.com/dwyl/app) 39 | 40 | # What? 🤷‍♀️ 41 | 42 | `Phoenix LiveView` lets us build RealTime collaborative apps 43 | without writing a line of `JavaScript`. 44 | This is an _example_ that anyone can understand in **`10 mins`**. 45 | 46 | # How? 💻 47 | 48 | Try the finished app before you try to build it: 49 | 50 | https://liveview-stopwatch.fly.dev/ 51 | 52 | ![stopwatch](https://user-images.githubusercontent.com/194400/174432051-5199369d-df07-4809-a758-24d3738535f7.png) 53 | 54 | Once you've tried it, come back and **_build_ it**! 55 | ## Create a new "barebones" Phonenix application: 56 | 57 | ```sh 58 | mix phx.new stopwatch --no-mailer --no-dashboard --no-gettext --no-ecto 59 | ``` 60 | 61 | ## Create folders and files for `LiveView`: 62 | 63 | ```sh 64 | mkdir lib/stopwatch_web/live 65 | touch lib/stopwatch_web/live/stopwatch_live.ex 66 | touch lib/stopwatch_web/views/stopwatch_view.ex 67 | mkdir lib/stopwatch_web/templates/stopwatch 68 | touch lib/stopwatch_web/templates/stopwatch/stopwatch.html.heex 69 | ``` 70 | 71 | ## Update `router.ex` 72 | 73 | In `lib/stopwatch_web/router.ex` update the "/" endpoint: 74 | 75 | ```elixir 76 | live("/", StopwatchLive) 77 | ``` 78 | 79 | ## Create `LiveView` logic 80 | 81 | Create the 82 | `mount`, `render`, `handle_event` and `handle_info` 83 | functions 84 | in StopwatchLive module: 85 | `lib/stopwatch_web/live/stopwatch_live.ex` 86 | 87 | ```elixir 88 | defmodule StopwatchWeb.StopwatchLive do 89 | use StopwatchWeb, :live_view 90 | 91 | def mount(_params, _session, socket) do 92 | {:ok, assign(socket, time: ~T[00:00:00], timer_status: :stopped)} 93 | end 94 | 95 | def render(assigns) do 96 | Phoenix.View.render(StopwatchWeb.StopwatchView, "stopwatch.html", assigns) 97 | end 98 | 99 | def handle_event("start", _value, socket) do 100 | Process.send_after(self(), :tick, 1000) 101 | {:noreply, assign(socket, :timer_status, :running)} 102 | end 103 | 104 | def handle_event("stop", _value, socket) do 105 | {:noreply, assign(socket, :timer_status, :stopped)} 106 | end 107 | 108 | def handle_info(:tick, socket) do 109 | if socket.assigns.timer_status == :running do 110 | Process.send_after(self(), :tick, 1000) 111 | time = Time.add(socket.assigns.time, 1, :second) 112 | {:noreply, assign(socket, :time, time)} 113 | else 114 | {:noreply, socket} 115 | end 116 | end 117 | end 118 | ``` 119 | 120 | In `mount` `:time` is initialised using the `~T` sigil to create a Time value, 121 | and `:timer_status` is set to `:stopped`, this value is used to display the correct 122 | start/stop button on the template. 123 | 124 | The `render` function call the `stopwatch.html` template with the `:time` and 125 | `:timer_status` defined in the `assigns`. 126 | 127 | There are two `handle_event` functions. One for starting the timer and the other 128 | to stop it. When the stopwatch start we send a new `:tick` event after 1 second and 129 | set the timer status to `:running`. The `stop` event only switch the timer status 130 | back to `stopped`. 131 | 132 | Finally the `handle_info` function manages the `:tick` event. If the status is 133 | `:running` when send another `:tick` event after 1 second and increment the `:timer` 134 | value with 1 second. 135 | 136 | ## Update Root Template 137 | 138 | Update the 139 | `lib/stopwatch_web/templates/layout/root.hml.heex` 140 | with the following body: 141 | 142 | ```html 143 | 144 | <%= @inner_content %> 145 | 146 | ``` 147 | 148 | ## Create View 149 | 150 | Create the `StopwatchView` module in `lib/stopwatch_web/views/stopwatch_view.ex` 151 | 152 | ```elixir defmodule StopwatchWeb.StopwatchView do 153 | use StopwatchWeb, :view 154 | end 155 | ``` 156 | 157 | ## Create Template 158 | 159 | Finally create the templates in 160 | `lib/stopwatch_web/templates/stopwatch/stopwatch.html.heex`: 161 | 162 | ```html 163 |

<%= @time |> Time.truncate(:second) |> Time.to_string() %>

164 | <%= if @timer_status == :stopped do %> 165 | 166 | <% end %> 167 | 168 | <%= if @timer_status == :running do %> 169 | 170 | <% end %> 171 | ``` 172 | 173 | If you run the server with 174 | `mix phx.server` 175 | you should now be able 176 | to start/stop the stopwatch. 177 | 178 | ## Sync Stopwatch 179 | 180 | So far the application will create a new timer for each client. 181 | That is good but doesn't really showcase the power of `LiveView`. 182 | We might aswell just be using _any_ other framework/library. 183 | To really see the power of using `LiveView`, 184 | we're going to use its' super power - 185 | lightweight websocket "channels" - 186 | to create a _collaborative_ stopwatch experience! 187 | 188 | 196 | 197 | To be able to sync a timer 198 | between all the connected clients 199 | we can move the stopwatch logic 200 | to its own module and use 201 | [`Agent`](https://elixir-lang.org/getting-started/mix-otp/agent.html). 202 | 203 | Create `lib/stopwatch/timer.ex` file and add the folowing content: 204 | 205 | ```elixir 206 | defmodule Stopwatch.Timer do 207 | use Agent 208 | alias Phoenix.PubSub 209 | 210 | def start_link(opts) do 211 | Agent.start_link(fn -> {:stopped, ~T[00:00:00]} end, opts) 212 | end 213 | 214 | def get_timer_state(timer) do 215 | Agent.get(timer, fn state -> state end) 216 | end 217 | 218 | def start_timer(timer) do 219 | Agent.update(timer, fn {_timer_status, time} -> {:running, time} end) 220 | notify() 221 | end 222 | 223 | def stop_timer(timer) do 224 | Agent.update(timer, fn {_timer_status, time} -> {:stopped, time} end) 225 | notify() 226 | end 227 | 228 | def tick(timer) do 229 | Agent.update(timer, fn {timer_status, timer} -> 230 | {timer_status, Time.add(timer, 1, :second)} 231 | end) 232 | 233 | notify() 234 | end 235 | 236 | def subscribe() do 237 | PubSub.subscribe(Stopwatch.PubSub, "liveview_stopwatch") 238 | end 239 | 240 | def notify() do 241 | PubSub.broadcast(Stopwatch.PubSub, "liveview_stopwatch", :timer_updated) 242 | end 243 | end 244 | ``` 245 | 246 | The agent defines the state of the stopwatch 247 | as a tuple `{timer_status, time}`. 248 | We defined the 249 | `get_timer_state/1`, `start_timer/1`, `stop_timer/1` 250 | and `tick/1` functions 251 | which are responsible for updating the tuple. 252 | 253 | Finally the last two funtions: 254 | `subscribe/0` and `notify/0` 255 | are responsible for listening and sending 256 | the `:timer_updated` event via PubSub to the clients. 257 | 258 | 259 | Now we have the Timer agent defined 260 | we can tell the application to create 261 | a stopwatch when the application starts. 262 | Update the `lib/stopwatch/application.ex` file 263 | to add the `StopwatchTimer` 264 | in the supervision tree: 265 | 266 | ```elixir 267 | children = [ 268 | # Start the Telemetry supervisor 269 | StopwatchWeb.Telemetry, 270 | # Start the PubSub system 271 | {Phoenix.PubSub, name: Stopwatch.PubSub}, 272 | # Start the Endpoint (http/https) 273 | StopwatchWeb.Endpoint, 274 | # Start a worker by calling: Stopwatch.Worker.start_link(arg) 275 | # {Stopwatch.Worker, arg} 276 | {Stopwatch.Timer, name: Stopwatch.Timer} # Create timer 277 | ] 278 | ``` 279 | 280 | We define the timer name as `Stopwatch.Timer`. 281 | This name could be any `atom` 282 | and doesn't have to be an existing module name. 283 | It is just a unique way to find the timer. 284 | 285 | We can now update our `LiveView` logic 286 | to use the function defined in `Stopwatch.Timer`. 287 | Update 288 | `lib/stopwatch_web/live/stopwatch_live.ex`: 289 | 290 | ```elixir 291 | defmodule StopwatchWeb.StopwatchLive do 292 | use StopwatchWeb, :live_view 293 | 294 | def mount(_params, _session, socket) do 295 | if connected?(socket), do: Stopwatch.Timer.subscribe() 296 | 297 | {timer_status, time} = Stopwatch.Timer.get_timer_state(Stopwatch.Timer) 298 | {:ok, assign(socket, time: time, timer_status: timer_status)} 299 | end 300 | 301 | def render(assigns) do 302 | Phoenix.View.render(StopwatchWeb.StopwatchView, "stopwatch.html", assigns) 303 | end 304 | 305 | def handle_event("start", _value, socket) do 306 | Process.send_after(self(), :tick, 1000) 307 | Stopwatch.Timer.start_timer(Stopwatch.Timer) 308 | {:noreply, socket} 309 | end 310 | 311 | def handle_event("stop", _value, socket) do 312 | Stopwatch.Timer.stop_timer(Stopwatch.Timer) 313 | {:noreply, socket} 314 | end 315 | 316 | def handle_info(:timer_updated, socket) do 317 | {timer_status, time} = Stopwatch.Timer.get_timer_state(Stopwatch.Timer) 318 | {:noreply, assign(socket, time: time, timer_status: timer_status)} 319 | end 320 | 321 | def handle_info(:tick, socket) do 322 | {timer_status, _time} = Stopwatch.Timer.get_timer_state(Stopwatch.Timer) 323 | 324 | if timer_status == :running do 325 | Process.send_after(self(), :tick, 1000) 326 | Stopwatch.Timer.tick(Stopwatch.Timer) 327 | {:noreply, socket} 328 | else 329 | {:noreply, socket} 330 | end 331 | end 332 | end 333 | ``` 334 | 335 | In `mount/3`, when the socket is connected 336 | we subscribe the client to the PubSub channel. 337 | This will allow our `LiveView` 338 | to listen for events from other clients. 339 | 340 | The `start`, `stop` and `tick` events 341 | are now calling the 342 | `start_timer`, `stop_timer` and `tick` functions 343 | from `Timer`, 344 | and we return `{:ok, socket}` 345 | without any changes on the `assigns`. 346 | All the updates are now done 347 | in the new 348 | `handle_info(:timer_updated, socket)` 349 | function. 350 | The `:timer_updated` event 351 | is sent by `PubSub` 352 | each time the timer state is changed. 353 | 354 | 355 | If you run the application: 356 | ```sh 357 | mix phx.server 358 | ``` 359 | 360 | And open it in two different clients 361 | you should now have a synchronised stopwatch! 362 | 363 | ![liveview-stopwatch-sync](https://user-images.githubusercontent.com/194400/174431168-d37e5382-f3e1-4c99-bd3b-bd3500a5035e.gif) 364 | 365 | To _test_ our new `Stopwatch.Timer` agent, 366 | we can add the following code to 367 | `test/stopwatch/timer_test.exs`: 368 | 369 | ```elixir 370 | defmodule Stopwatch.TimerTest do 371 | use ExUnit.Case, async: true 372 | 373 | setup context do 374 | start_supervised!({Stopwatch.Timer, name: context.test}) 375 | %{timer: context.test} 376 | end 377 | 378 | test "Timer agent is working!", %{timer: timer} do 379 | assert {:stopped, ~T[00:00:00]} == Stopwatch.Timer.get_timer_state(timer) 380 | assert :ok = Stopwatch.Timer.start_timer(timer) 381 | assert :ok = Stopwatch.Timer.tick(timer) 382 | assert {:running, time} = Stopwatch.Timer.get_timer_state(timer) 383 | assert Time.truncate(time, :second) == ~T[00:00:01] 384 | assert :ok = Stopwatch.Timer.stop_timer(timer) 385 | assert {:stopped, _time} = Stopwatch.Timer.get_timer_state(timer) 386 | end 387 | 388 | 389 | test "Timer is reset", %{timer: timer} do 390 | assert :ok = Stopwatch.Timer.start_timer(timer) 391 | :ok = Stopwatch.Timer.tick(timer) 392 | :ok = Stopwatch.Timer.tick(timer) 393 | {:running, time} = Stopwatch.Timer.get_timer_state(timer) 394 | assert Time.truncate(time, :second) == ~T[00:00:02] 395 | Stopwatch.Timer.reset(timer) 396 | assert {:stopped, ~T[00:00:00]} == Stopwatch.Timer.get_timer_state(timer) 397 | end 398 | end 399 | ``` 400 | 401 | We use the `setup` function 402 | to create a new timer for each test. 403 | `start_supervised!` takes care of creating 404 | and stopping the process timer for the tests. 405 | Since `mix run` will automatically run the `Timer` 406 | defined in `application.ex`, 407 | i.e. the Timer with the name `Stopwatch.Timer` 408 | we want to create new timers 409 | for the tests using other names to avoid conflicts. 410 | This is why we use `context.test` 411 | to define the name of the test `Timer` process. 412 | 413 | 414 | ## `GenServer` 415 | 416 | One problem with our current code is if the stopwatch is running and the 417 | client is closed (ex: browser tab closed) then the `tick` actions are stopped 418 | however the stopwatch status is still `:running`. 419 | This is because our live logic is responsible for updating the timer with: 420 | 421 | ```elixir 422 | def handle_info(:tick, socket) do 423 | {timer_status, _time} = Stopwatch.Timer.get_timer_state(Stopwatch.Timer) 424 | 425 | if timer_status == :running do 426 | Process.send_after(self(), :tick, 1000) 427 | Stopwatch.Timer.tick(Stopwatch.Timer) 428 | {:noreply, socket} 429 | else 430 | {:noreply, socket} 431 | end 432 | end 433 | ``` 434 | 435 | as `Process.send_after` will send the `:tick` message after 1s. 436 | When the client is closed the live process is also closed and the `tick` 437 | message is not sent anymore. 438 | 439 | Instead we want to move the ticking logic to the `Timer`. 440 | However `Agent` are not ideal to work with `Process.send_after` function and 441 | instead we are going to rewrite our `Timer` module using `GenServer`. 442 | 443 | Create the `lib/stopwatch/timer_server.ex` file and add the following: 444 | 445 | ```elixir 446 | defmodule Stopwatch.TimerServer do 447 | use GenServer 448 | alias Phoenix.PubSub 449 | 450 | # Client API 451 | 452 | def start_link(opts) do 453 | GenServer.start_link(__MODULE__, :ok, opts) 454 | end 455 | 456 | def start_timer(server) do 457 | GenServer.call(server, :start) 458 | end 459 | 460 | def stop_timer(server) do 461 | GenServer.call(server, :stop) 462 | end 463 | 464 | def get_timer_state(server) do 465 | GenServer.call(server, :state) 466 | end 467 | 468 | def reset(server) do 469 | GenServer.call(server, :reset) 470 | end 471 | 472 | # Server 473 | @impl true 474 | def init(:ok) do 475 | {:ok, {:stopped, ~T[00:00:00]}} 476 | end 477 | 478 | @impl true 479 | def handle_call(:start, _from, {_status, time}) do 480 | Process.send_after(self(), :tick, 1000) 481 | {:reply, :running, {:running, time}} 482 | end 483 | 484 | @impl true 485 | def handle_call(:stop, _from, {_status, time}) do 486 | {:reply, :stopped, {:stopped, time}} 487 | end 488 | 489 | @impl true 490 | def handle_info(:tick, {status, time} = stopwatch) do 491 | if status == :running do 492 | Process.send_after(self(), :tick, 1000) 493 | notify() 494 | {:noreply, {status, Time.add(time, 1, :second)}} 495 | else 496 | {:noreply, stopwatch} 497 | end 498 | end 499 | 500 | @impl true 501 | def handle_call(:state, _from, stopwatch) do 502 | {:reply, stopwatch, stopwatch} 503 | end 504 | 505 | @impl true 506 | def handle_call(:reset, _from, _stopwatch) do 507 | {:reply, :reset, {:stopped, ~T[00:00:00]}} 508 | end 509 | 510 | def subscribe() do 511 | PubSub.subscribe(Stopwatch.PubSub, "liveview_stopwatch") 512 | end 513 | 514 | def notify() do 515 | PubSub.broadcast(Stopwatch.PubSub, "liveview_stopwatch", :timer_updated) 516 | end 517 | end 518 | ``` 519 | 520 | Compared to `Agent`, `GenServer` splits functions into client and server logic. 521 | We can define the same client api functions name and use `hand_call` to send 522 | messages to the `GenServer` to `stop`, `start` and `reset` the stopwatch. 523 | 524 | The ticking process is now done by calling `Process.send_after(self(), :tick 1000)`. 525 | The `GenServer` will then manage the `tick` events with `handle_info(:tick, stopwatch)`. 526 | 527 | Now that we have defined our server, we need to update `lib/stopwatch/application.ex` to use 528 | the `GenServer` instead of the `Agent`: 529 | 530 | ```elixir 531 | children = [ 532 | # Start the Telemetry supervisor 533 | StopwatchWeb.Telemetry, 534 | # Start the PubSub system 535 | {Phoenix.PubSub, name: Stopwatch.PubSub}, 536 | # Start the Endpoint (http/https) 537 | StopwatchWeb.Endpoint, 538 | # Start a worker by calling: Stopwatch.Worker.start_link(arg) 539 | # {Stopwatch.Worker, arg} 540 | # {Stopwatch.Timer, name: Stopwatch.Timer} 541 | {Stopwatch.TimerServer, name: Stopwatch.TimerServer} 542 | ] 543 | ``` 544 | 545 | We have commented our `Stopwatch.Timer` agent and added the GenServer: 546 | `{Stopwatch.TimerServer, name: Stopwatch.TimerServer}` 547 | 548 | 549 | Finally we can update our live logic to use Stopwatch.TimerServer and to 550 | remove the `tick` logic from it: 551 | 552 | 553 | ```elixir 554 | defmodule StopwatchWeb.StopwatchLive do 555 | use StopwatchWeb, :live_view 556 | alias Stopwatch.TimerServer 557 | 558 | def mount(_params, _session, socket) do 559 | if connected?(socket), do: TimerServer.subscribe() 560 | 561 | {timer_status, time} = TimerServer.get_timer_state(Stopwatch.TimerServer) 562 | {:ok, assign(socket, time: time, timer_status: timer_status)} 563 | end 564 | 565 | def render(assigns) do 566 | Phoenix.View.render(StopwatchWeb.StopwatchView, "stopwatch.html", assigns) 567 | end 568 | 569 | def handle_event("start", _value, socket) do 570 | :running = TimerServer.start_timer(Stopwatch.TimerServer) 571 | TimerServer.notify() 572 | {:noreply, socket} 573 | end 574 | 575 | def handle_event("stop", _value, socket) do 576 | :stopped = TimerServer.stop_timer(Stopwatch.TimerServer) 577 | TimerServer.notify() 578 | {:noreply, socket} 579 | end 580 | 581 | def handle_event("reset", _value, socket) do 582 | :reset = TimerServer.reset(Stopwatch.TimerServer) 583 | TimerServer.notify() 584 | {:noreply, socket} 585 | end 586 | 587 | def handle_info(:timer_updated, socket) do 588 | {timer_status, time} = TimerServer.get_timer_state(Stopwatch.TimerServer) 589 | 590 | {:noreply, assign(socket, time: time, timer_status: timer_status)} 591 | end 592 | end 593 | ``` 594 | 595 | ## `Liveview` + `JavaScript` 596 | 597 | This section will combine 598 | `LiveView` and `JavaScript` 599 | to create the stopwatch logic. 600 | On `start|stop|reset` 601 | the `LiveView` will save 602 | the state of the stopwatch. 603 | The `JavaScript` is then responsible 604 | for handling the `start|stop`. 605 | 606 | Open the `lib/stopwatch_web/router.ex` file 607 | and define a new endpoint `/stopwatch-js`: 608 | 609 | ```elixir 610 | live("/stopwatch-js", StopwatchLiveJS) 611 | ``` 612 | 613 | Next create a new file at: 614 | `lib/stopwatch_web/live/stopwatch_live_js.ex` 615 | and add the 616 | `StopwatchLiveJS` module definition: 617 | 618 | 619 | ```elixir 620 | defmodule StopwatchWeb.StopwatchLiveJS do 621 | use StopwatchWeb, :live_view 622 | alias Stopwatch.TimerDB 623 | 624 | def mount(_params, _session, socket) do 625 | if connected?(socket), do: TimerDB.subscribe() 626 | 627 | # {timer_status, time} = TimerServer.get_timer_state(Stopwatch.TimerServer) 628 | # {:ok, assign(socket, time: time, timer_status: timer_status)} 629 | {status, start, stop} = TimerDB.get_timer_state(Stopwatch.TimerDB) 630 | TimerDB.notify() 631 | {:ok, assign(socket, timer_status: status, start: start, stop: stop)} 632 | end 633 | 634 | def render(assigns) do 635 | Phoenix.View.render(StopwatchWeb.StopwatchView, "stopwatch_js.html", assigns) 636 | end 637 | 638 | def handle_event("start", _value, socket) do 639 | TimerDB.start_timer(Stopwatch.TimerDB) 640 | 641 | TimerDB.notify() 642 | {:noreply, socket} 643 | end 644 | 645 | def handle_event("stop", _value, socket) do 646 | TimerDB.stop_timer(Stopwatch.TimerDB) 647 | TimerDB.notify() 648 | {:noreply, socket} 649 | end 650 | 651 | def handle_event("reset", _value, socket) do 652 | TimerDB.reset_timer(Stopwatch.TimerDB) 653 | TimerDB.notify() 654 | {:noreply, socket} 655 | end 656 | 657 | def handle_info(:timer_updated, socket) do 658 | {timer_status, start, stop} = TimerDB.get_timer_state(Stopwatch.TimerDB) 659 | socket = assign(socket, timer_status: timer_status, start: start, stop: stop) 660 | 661 | {:noreply, 662 | push_event(socket, "timerUpdated", %{timer_status: timer_status, start: start, stop: stop})} 663 | end 664 | end 665 | ``` 666 | 667 | `TimerDB` is an `Agent` used 668 | to store the stopwatch status as a `tuple`: 669 | `{status, start_time, stop_time}` 670 | 671 | Since we have created the project with 672 | `mix phx.new --no-ecto` 673 | it was easier 674 | to use `Agent` but you can also use a `database` 675 | (e.g. `Postgres`) 676 | to store the state 677 | of the stopwatch. 678 | 679 | The module listens for 680 | "start", "stop" and "reset" events, 681 | saves the updated status 682 | using the `TimerDB` module 683 | and notifies the changes 684 | to connected clients with 685 | `handle_info` 686 | 687 | The template is defined in: 688 | `lib/stopwatch_web/templates/stopwatch/stopwatch_js.html.heex`: 689 | 690 | ```html 691 |

00:00:00

692 | 693 | <%= if @timer_status == :stopped do %> 694 | 695 | <% end %> 696 | 697 | <%= if @timer_status == :running do %> 698 | 699 | <% end %> 700 | 701 | 702 | ``` 703 | 704 | Finally update the 705 | `assets/js/app.js` file 706 | to add the stopwatch logic: 707 | 708 | ```js 709 | timer = document.getElementById("timer") 710 | T = {ticking: false} 711 | window.addEventListener("phx:timerUpdated", e => { 712 | if (e.detail.timer_status == "running" && !T.ticking) { 713 | T.ticking = true 714 | T.timerInterval = setInterval(function() { 715 | text = timer_text(new Date(e.detail.start), Date.now()) 716 | timer.textContent = text 717 | }, 1000); 718 | } 719 | 720 | if (e.detail.timer_status == "stopped") { 721 | clearInterval(T.timerInterval) 722 | T.ticking = false 723 | text = timer_text(new Date(e.detail.start), new Date(e.detail.stop)) 724 | timer.textContent = text 725 | } 726 | }) 727 | 728 | function leftPad(val) { 729 | return val < 10 ? '0' + String(val) : val; 730 | } 731 | 732 | function timer_text(start, current) { 733 | let h="00", m="00", s="00"; 734 | const diff = current - start; 735 | // seconds 736 | if(diff > 1000) { 737 | s = Math.floor(diff / 1000); 738 | s = s > 60 ? s % 60 : s; 739 | s = leftPad(s); 740 | } 741 | // minutes 742 | if(diff > 60000) { 743 | m = Math.floor(diff/60000); 744 | m = m > 60 ? m % 60 : leftPad(m); 745 | } 746 | // hours 747 | if(diff > 3600000) { 748 | h = Math.floor(diff/3600000); 749 | h = leftPad(h) 750 | } 751 | 752 | return h + ':' + m + ':' + s; 753 | } 754 | ``` 755 | 756 | The important part is 757 | where we trigger the ticking process: 758 | 759 | ```js 760 | window.addEventListener("phx:timerUpdated", e => { 761 | if (e.detail.timer_status == "running" && !T.ticking) { 762 | T.ticking = true 763 | T.timerInterval = setInterval(function() { 764 | text = timer_text(new Date(e.detail.start), Date.now()) 765 | timer.textContent = text 766 | }, 1000); 767 | } 768 | }) 769 | ``` 770 | 771 | `setInterval` is called 772 | when the stopwatch is started 773 | and every `1s` we _compare_ 774 | the `start` time (unix time/epoch) 775 | to the `current` `Date.now()` time. 776 | 777 | The rest of the logic is borrowed from: 778 | [dwyl/learn-alpine.js#stopwatch](https://github.com/dwyl/learn-alpine.js#stopwatch-%EF%B8%8F) 779 | 780 | ## What's _next_? 781 | 782 | If you found this example useful, 783 | please ⭐️ the GitHub repository 784 | so we (_and others_) know you liked it! 785 | 786 | Your feedback is always very welcome! 787 | 788 | If you think of other features 789 | you want to add, 790 | please 791 | [**open an issue**](https://github.com/dwyl/phoenix-liveview-stopwatch/issues) 792 | to discuss! 793 | -------------------------------------------------------------------------------- /priv/static/assets/app.css: -------------------------------------------------------------------------------- 1 | /* css/phoenix.css */ 2 | *, 3 | *:after, 4 | *:before { 5 | box-sizing: inherit; 6 | } 7 | html { 8 | box-sizing: border-box; 9 | font-size: 62.5%; 10 | } 11 | body { 12 | color: #000000; 13 | font-family: 14 | "Helvetica Neue", 15 | "Helvetica", 16 | "Arial", 17 | sans-serif; 18 | font-size: 1.6em; 19 | font-weight: 300; 20 | letter-spacing: .01em; 21 | line-height: 1.6; 22 | } 23 | blockquote { 24 | border-left: 0.3rem solid #d1d1d1; 25 | margin-left: 0; 26 | margin-right: 0; 27 | padding: 1rem 1.5rem; 28 | } 29 | blockquote *:last-child { 30 | margin-bottom: 0; 31 | } 32 | .button, 33 | button, 34 | input[type=button], 35 | input[type=reset], 36 | input[type=submit] { 37 | background-color: #0069d9; 38 | border: 0.1rem solid #0069d9; 39 | border-radius: .4rem; 40 | color: #fff; 41 | cursor: pointer; 42 | display: inline-block; 43 | font-size: 1.1rem; 44 | font-weight: 700; 45 | height: 3.8rem; 46 | letter-spacing: .1rem; 47 | line-height: 3.8rem; 48 | padding: 0 3.0rem; 49 | text-align: center; 50 | text-decoration: none; 51 | text-transform: uppercase; 52 | white-space: nowrap; 53 | } 54 | .button:focus, 55 | .button:hover, 56 | button:focus, 57 | button:hover, 58 | input[type=button]:focus, 59 | input[type=button]:hover, 60 | input[type=reset]:focus, 61 | input[type=reset]:hover, 62 | input[type=submit]:focus, 63 | input[type=submit]:hover { 64 | background-color: #606c76; 65 | border-color: #606c76; 66 | color: #fff; 67 | outline: 0; 68 | } 69 | .button[disabled], 70 | button[disabled], 71 | input[type=button][disabled], 72 | input[type=reset][disabled], 73 | input[type=submit][disabled] { 74 | cursor: default; 75 | opacity: .5; 76 | } 77 | .button[disabled]:focus, 78 | .button[disabled]:hover, 79 | button[disabled]:focus, 80 | button[disabled]:hover, 81 | input[type=button][disabled]:focus, 82 | input[type=button][disabled]:hover, 83 | input[type=reset][disabled]:focus, 84 | input[type=reset][disabled]:hover, 85 | input[type=submit][disabled]:focus, 86 | input[type=submit][disabled]:hover { 87 | background-color: #0069d9; 88 | border-color: #0069d9; 89 | } 90 | .button.button-outline, 91 | button.button-outline, 92 | input[type=button].button-outline, 93 | input[type=reset].button-outline, 94 | input[type=submit].button-outline { 95 | background-color: transparent; 96 | color: #0069d9; 97 | } 98 | .button.button-outline:focus, 99 | .button.button-outline:hover, 100 | button.button-outline:focus, 101 | button.button-outline:hover, 102 | input[type=button].button-outline:focus, 103 | input[type=button].button-outline:hover, 104 | input[type=reset].button-outline:focus, 105 | input[type=reset].button-outline:hover, 106 | input[type=submit].button-outline:focus, 107 | input[type=submit].button-outline:hover { 108 | background-color: transparent; 109 | border-color: #606c76; 110 | color: #606c76; 111 | } 112 | .button.button-outline[disabled]:focus, 113 | .button.button-outline[disabled]:hover, 114 | button.button-outline[disabled]:focus, 115 | button.button-outline[disabled]:hover, 116 | input[type=button].button-outline[disabled]:focus, 117 | input[type=button].button-outline[disabled]:hover, 118 | input[type=reset].button-outline[disabled]:focus, 119 | input[type=reset].button-outline[disabled]:hover, 120 | input[type=submit].button-outline[disabled]:focus, 121 | input[type=submit].button-outline[disabled]:hover { 122 | border-color: inherit; 123 | color: #0069d9; 124 | } 125 | .button.button-clear, 126 | button.button-clear, 127 | input[type=button].button-clear, 128 | input[type=reset].button-clear, 129 | input[type=submit].button-clear { 130 | background-color: transparent; 131 | border-color: transparent; 132 | color: #0069d9; 133 | } 134 | .button.button-clear:focus, 135 | .button.button-clear:hover, 136 | button.button-clear:focus, 137 | button.button-clear:hover, 138 | input[type=button].button-clear:focus, 139 | input[type=button].button-clear:hover, 140 | input[type=reset].button-clear:focus, 141 | input[type=reset].button-clear:hover, 142 | input[type=submit].button-clear:focus, 143 | input[type=submit].button-clear:hover { 144 | background-color: transparent; 145 | border-color: transparent; 146 | color: #606c76; 147 | } 148 | .button.button-clear[disabled]:focus, 149 | .button.button-clear[disabled]:hover, 150 | button.button-clear[disabled]:focus, 151 | button.button-clear[disabled]:hover, 152 | input[type=button].button-clear[disabled]:focus, 153 | input[type=button].button-clear[disabled]:hover, 154 | input[type=reset].button-clear[disabled]:focus, 155 | input[type=reset].button-clear[disabled]:hover, 156 | input[type=submit].button-clear[disabled]:focus, 157 | input[type=submit].button-clear[disabled]:hover { 158 | color: #0069d9; 159 | } 160 | code { 161 | background: #f4f5f6; 162 | border-radius: .4rem; 163 | font-size: 86%; 164 | margin: 0 .2rem; 165 | padding: .2rem .5rem; 166 | white-space: nowrap; 167 | } 168 | pre { 169 | background: #f4f5f6; 170 | border-left: 0.3rem solid #0069d9; 171 | overflow-y: hidden; 172 | } 173 | pre > code { 174 | border-radius: 0; 175 | display: block; 176 | padding: 1rem 1.5rem; 177 | white-space: pre; 178 | } 179 | hr { 180 | border: 0; 181 | border-top: 0.1rem solid #f4f5f6; 182 | margin: 3.0rem 0; 183 | } 184 | input[type=color], 185 | input[type=date], 186 | input[type=datetime], 187 | input[type=datetime-local], 188 | input[type=email], 189 | input[type=month], 190 | input[type=number], 191 | input[type=password], 192 | input[type=search], 193 | input[type=tel], 194 | input[type=text], 195 | input[type=url], 196 | input[type=week], 197 | input:not([type]), 198 | textarea, 199 | select { 200 | -webkit-appearance: none; 201 | background-color: transparent; 202 | border: 0.1rem solid #d1d1d1; 203 | border-radius: .4rem; 204 | box-shadow: none; 205 | box-sizing: inherit; 206 | height: 3.8rem; 207 | padding: .6rem 1.0rem .7rem; 208 | width: 100%; 209 | } 210 | input[type=color]:focus, 211 | input[type=date]:focus, 212 | input[type=datetime]:focus, 213 | input[type=datetime-local]:focus, 214 | input[type=email]:focus, 215 | input[type=month]:focus, 216 | input[type=number]:focus, 217 | input[type=password]:focus, 218 | input[type=search]:focus, 219 | input[type=tel]:focus, 220 | input[type=text]:focus, 221 | input[type=url]:focus, 222 | input[type=week]:focus, 223 | input:not([type]):focus, 224 | textarea:focus, 225 | select:focus { 226 | border-color: #0069d9; 227 | outline: 0; 228 | } 229 | select { 230 | background: url('data:image/svg+xml;utf8,') center right no-repeat; 231 | padding-right: 3.0rem; 232 | } 233 | select:focus { 234 | background-image: url('data:image/svg+xml;utf8,'); 235 | } 236 | select[multiple] { 237 | background: none; 238 | height: auto; 239 | } 240 | textarea { 241 | min-height: 6.5rem; 242 | } 243 | label, 244 | legend { 245 | display: block; 246 | font-size: 1.6rem; 247 | font-weight: 700; 248 | margin-bottom: .5rem; 249 | } 250 | fieldset { 251 | border-width: 0; 252 | padding: 0; 253 | } 254 | input[type=checkbox], 255 | input[type=radio] { 256 | display: inline; 257 | } 258 | .label-inline { 259 | display: inline-block; 260 | font-weight: normal; 261 | margin-left: .5rem; 262 | } 263 | .container { 264 | margin: 0 auto; 265 | max-width: 112.0rem; 266 | padding: 0 2.0rem; 267 | position: relative; 268 | width: 100%; 269 | } 270 | .row { 271 | display: flex; 272 | flex-direction: column; 273 | padding: 0; 274 | width: 100%; 275 | } 276 | .row.row-no-padding { 277 | padding: 0; 278 | } 279 | .row.row-no-padding > .column { 280 | padding: 0; 281 | } 282 | .row.row-wrap { 283 | flex-wrap: wrap; 284 | } 285 | .row.row-top { 286 | align-items: flex-start; 287 | } 288 | .row.row-bottom { 289 | align-items: flex-end; 290 | } 291 | .row.row-center { 292 | align-items: center; 293 | } 294 | .row.row-stretch { 295 | align-items: stretch; 296 | } 297 | .row.row-baseline { 298 | align-items: baseline; 299 | } 300 | .row .column { 301 | display: block; 302 | flex: 1 1 auto; 303 | margin-left: 0; 304 | max-width: 100%; 305 | width: 100%; 306 | } 307 | .row .column.column-offset-10 { 308 | margin-left: 10%; 309 | } 310 | .row .column.column-offset-20 { 311 | margin-left: 20%; 312 | } 313 | .row .column.column-offset-25 { 314 | margin-left: 25%; 315 | } 316 | .row .column.column-offset-33, 317 | .row .column.column-offset-34 { 318 | margin-left: 33.3333%; 319 | } 320 | .row .column.column-offset-40 { 321 | margin-left: 40%; 322 | } 323 | .row .column.column-offset-50 { 324 | margin-left: 50%; 325 | } 326 | .row .column.column-offset-60 { 327 | margin-left: 60%; 328 | } 329 | .row .column.column-offset-66, 330 | .row .column.column-offset-67 { 331 | margin-left: 66.6666%; 332 | } 333 | .row .column.column-offset-75 { 334 | margin-left: 75%; 335 | } 336 | .row .column.column-offset-80 { 337 | margin-left: 80%; 338 | } 339 | .row .column.column-offset-90 { 340 | margin-left: 90%; 341 | } 342 | .row .column.column-10 { 343 | flex: 0 0 10%; 344 | max-width: 10%; 345 | } 346 | .row .column.column-20 { 347 | flex: 0 0 20%; 348 | max-width: 20%; 349 | } 350 | .row .column.column-25 { 351 | flex: 0 0 25%; 352 | max-width: 25%; 353 | } 354 | .row .column.column-33, 355 | .row .column.column-34 { 356 | flex: 0 0 33.3333%; 357 | max-width: 33.3333%; 358 | } 359 | .row .column.column-40 { 360 | flex: 0 0 40%; 361 | max-width: 40%; 362 | } 363 | .row .column.column-50 { 364 | flex: 0 0 50%; 365 | max-width: 50%; 366 | } 367 | .row .column.column-60 { 368 | flex: 0 0 60%; 369 | max-width: 60%; 370 | } 371 | .row .column.column-66, 372 | .row .column.column-67 { 373 | flex: 0 0 66.6666%; 374 | max-width: 66.6666%; 375 | } 376 | .row .column.column-75 { 377 | flex: 0 0 75%; 378 | max-width: 75%; 379 | } 380 | .row .column.column-80 { 381 | flex: 0 0 80%; 382 | max-width: 80%; 383 | } 384 | .row .column.column-90 { 385 | flex: 0 0 90%; 386 | max-width: 90%; 387 | } 388 | .row .column .column-top { 389 | align-self: flex-start; 390 | } 391 | .row .column .column-bottom { 392 | align-self: flex-end; 393 | } 394 | .row .column .column-center { 395 | align-self: center; 396 | } 397 | @media (min-width: 40rem) { 398 | .row { 399 | flex-direction: row; 400 | margin-left: -1.0rem; 401 | width: calc(100% + 2.0rem); 402 | } 403 | .row .column { 404 | margin-bottom: inherit; 405 | padding: 0 1.0rem; 406 | } 407 | } 408 | a { 409 | color: #0069d9; 410 | text-decoration: none; 411 | } 412 | a:focus, 413 | a:hover { 414 | color: #606c76; 415 | } 416 | dl, 417 | ol, 418 | ul { 419 | list-style: none; 420 | margin-top: 0; 421 | padding-left: 0; 422 | } 423 | dl dl, 424 | dl ol, 425 | dl ul, 426 | ol dl, 427 | ol ol, 428 | ol ul, 429 | ul dl, 430 | ul ol, 431 | ul ul { 432 | font-size: 90%; 433 | margin: 1.5rem 0 1.5rem 3.0rem; 434 | } 435 | ol { 436 | list-style: decimal inside; 437 | } 438 | ul { 439 | list-style: circle inside; 440 | } 441 | .button, 442 | button, 443 | dd, 444 | dt, 445 | li { 446 | margin-bottom: 1.0rem; 447 | } 448 | fieldset, 449 | input, 450 | select, 451 | textarea { 452 | margin-bottom: 1.5rem; 453 | } 454 | blockquote, 455 | dl, 456 | figure, 457 | form, 458 | ol, 459 | p, 460 | pre, 461 | table, 462 | ul { 463 | margin-bottom: 2.5rem; 464 | } 465 | table { 466 | border-spacing: 0; 467 | display: block; 468 | overflow-x: auto; 469 | text-align: left; 470 | width: 100%; 471 | } 472 | td, 473 | th { 474 | border-bottom: 0.1rem solid #e1e1e1; 475 | padding: 1.2rem 1.5rem; 476 | } 477 | td:first-child, 478 | th:first-child { 479 | padding-left: 0; 480 | } 481 | td:last-child, 482 | th:last-child { 483 | padding-right: 0; 484 | } 485 | @media (min-width: 40rem) { 486 | table { 487 | display: table; 488 | overflow-x: initial; 489 | } 490 | } 491 | b, 492 | strong { 493 | font-weight: bold; 494 | } 495 | p { 496 | margin-top: 0; 497 | } 498 | h1, 499 | h2, 500 | h3, 501 | h4, 502 | h5, 503 | h6 { 504 | font-weight: 300; 505 | letter-spacing: -.1rem; 506 | margin-bottom: 2.0rem; 507 | margin-top: 0; 508 | } 509 | h1 { 510 | font-size: 4.6rem; 511 | line-height: 1.2; 512 | } 513 | h2 { 514 | font-size: 3.6rem; 515 | line-height: 1.25; 516 | } 517 | h3 { 518 | font-size: 2.8rem; 519 | line-height: 1.3; 520 | } 521 | h4 { 522 | font-size: 2.2rem; 523 | letter-spacing: -.08rem; 524 | line-height: 1.35; 525 | } 526 | h5 { 527 | font-size: 1.8rem; 528 | letter-spacing: -.05rem; 529 | line-height: 1.5; 530 | } 531 | h6 { 532 | font-size: 1.6rem; 533 | letter-spacing: 0; 534 | line-height: 1.4; 535 | } 536 | img { 537 | max-width: 100%; 538 | } 539 | .clearfix:after { 540 | clear: both; 541 | content: " "; 542 | display: table; 543 | } 544 | .float-left { 545 | float: left; 546 | } 547 | .float-right { 548 | float: right; 549 | } 550 | h1 { 551 | font-size: 3.6rem; 552 | line-height: 1.25; 553 | } 554 | h2 { 555 | font-size: 2.8rem; 556 | line-height: 1.3; 557 | } 558 | h3 { 559 | font-size: 2.2rem; 560 | letter-spacing: -.08rem; 561 | line-height: 1.35; 562 | } 563 | h4 { 564 | font-size: 1.8rem; 565 | letter-spacing: -.05rem; 566 | line-height: 1.5; 567 | } 568 | h5 { 569 | font-size: 1.6rem; 570 | letter-spacing: 0; 571 | line-height: 1.4; 572 | } 573 | h6 { 574 | font-size: 1.4rem; 575 | letter-spacing: 0; 576 | line-height: 1.2; 577 | } 578 | pre { 579 | padding: 1em; 580 | } 581 | .container { 582 | margin: 0 auto; 583 | max-width: 80.0rem; 584 | padding: 0 2.0rem; 585 | position: relative; 586 | width: 100%; 587 | } 588 | select { 589 | width: auto; 590 | } 591 | .phx-hero { 592 | text-align: center; 593 | border-bottom: 1px solid #e3e3e3; 594 | background: #eee; 595 | border-radius: 6px; 596 | padding: 3em 3em 1em; 597 | margin-bottom: 3rem; 598 | font-weight: 200; 599 | font-size: 120%; 600 | } 601 | .phx-hero input { 602 | background: #ffffff; 603 | } 604 | .phx-logo { 605 | min-width: 300px; 606 | margin: 1rem; 607 | display: block; 608 | } 609 | .phx-logo img { 610 | width: auto; 611 | display: block; 612 | } 613 | header { 614 | width: 100%; 615 | background: #fdfdfd; 616 | border-bottom: 1px solid #eaeaea; 617 | margin-bottom: 2rem; 618 | } 619 | header section { 620 | align-items: center; 621 | display: flex; 622 | flex-direction: column; 623 | justify-content: space-between; 624 | } 625 | header section :first-child { 626 | order: 2; 627 | } 628 | header section :last-child { 629 | order: 1; 630 | } 631 | header nav ul, 632 | header nav li { 633 | margin: 0; 634 | padding: 0; 635 | display: block; 636 | text-align: right; 637 | white-space: nowrap; 638 | } 639 | header nav ul { 640 | margin: 1rem; 641 | margin-top: 0; 642 | } 643 | header nav a { 644 | display: block; 645 | } 646 | @media (min-width: 40.0rem) { 647 | header section { 648 | flex-direction: row; 649 | } 650 | header nav ul { 651 | margin: 1rem; 652 | } 653 | .phx-logo { 654 | flex-basis: 527px; 655 | margin: 2rem 1rem; 656 | } 657 | } 658 | 659 | /* css/app.css */ 660 | .alert { 661 | padding: 15px; 662 | margin-bottom: 20px; 663 | border: 1px solid transparent; 664 | border-radius: 4px; 665 | } 666 | .alert-info { 667 | color: #31708f; 668 | background-color: #d9edf7; 669 | border-color: #bce8f1; 670 | } 671 | .alert-warning { 672 | color: #8a6d3b; 673 | background-color: #fcf8e3; 674 | border-color: #faebcc; 675 | } 676 | .alert-danger { 677 | color: #a94442; 678 | background-color: #f2dede; 679 | border-color: #ebccd1; 680 | } 681 | .alert p { 682 | margin-bottom: 0; 683 | } 684 | .alert:empty { 685 | display: none; 686 | } 687 | .invalid-feedback { 688 | color: #a94442; 689 | display: block; 690 | margin: -1rem 0 2rem; 691 | } 692 | .phx-no-feedback.invalid-feedback, 693 | .phx-no-feedback .invalid-feedback { 694 | display: none; 695 | } 696 | .phx-click-loading { 697 | opacity: 0.5; 698 | transition: opacity 1s ease-out; 699 | } 700 | .phx-loading { 701 | cursor: wait; 702 | } 703 | .phx-modal { 704 | opacity: 1 !important; 705 | position: fixed; 706 | z-index: 1; 707 | left: 0; 708 | top: 0; 709 | width: 100%; 710 | height: 100%; 711 | overflow: auto; 712 | background-color: rgba(0, 0, 0, 0.4); 713 | } 714 | .phx-modal-content { 715 | background-color: #fefefe; 716 | margin: 15vh auto; 717 | padding: 20px; 718 | border: 1px solid #888; 719 | width: 80%; 720 | } 721 | .phx-modal-close { 722 | color: #aaa; 723 | float: right; 724 | font-size: 28px; 725 | font-weight: bold; 726 | } 727 | .phx-modal-close:hover, 728 | .phx-modal-close:focus { 729 | color: black; 730 | text-decoration: none; 731 | cursor: pointer; 732 | } 733 | .fade-in-scale { 734 | animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys; 735 | } 736 | .fade-out-scale { 737 | animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys; 738 | } 739 | .fade-in { 740 | animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys; 741 | } 742 | .fade-out { 743 | animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys; 744 | } 745 | @keyframes fade-in-scale-keys { 746 | 0% { 747 | scale: 0.95; 748 | opacity: 0; 749 | } 750 | 100% { 751 | scale: 1.0; 752 | opacity: 1; 753 | } 754 | } 755 | @keyframes fade-out-scale-keys { 756 | 0% { 757 | scale: 1.0; 758 | opacity: 1; 759 | } 760 | 100% { 761 | scale: 0.95; 762 | opacity: 0; 763 | } 764 | } 765 | @keyframes fade-in-keys { 766 | 0% { 767 | opacity: 0; 768 | } 769 | 100% { 770 | opacity: 1; 771 | } 772 | } 773 | @keyframes fade-out-keys { 774 | 0% { 775 | opacity: 1; 776 | } 777 | 100% { 778 | opacity: 0; 779 | } 780 | } 781 | /*# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiLi4vLi4vLi4vYXNzZXRzL2Nzcy9waG9lbml4LmNzcyIsICIuLi8uLi8uLi9hc3NldHMvY3NzL2FwcC5jc3MiXSwKICAic291cmNlc0NvbnRlbnQiOiBbIi8qIEluY2x1ZGVzIHNvbWUgZGVmYXVsdCBzdHlsZSBmb3IgdGhlIHN0YXJ0ZXIgYXBwbGljYXRpb24uXG4gKiBUaGlzIGNhbiBiZSBzYWZlbHkgZGVsZXRlZCB0byBzdGFydCBmcmVzaC5cbiAqL1xuXG4vKiBNaWxsaWdyYW0gdjEuNC4xIGh0dHBzOi8vbWlsbGlncmFtLmdpdGh1Yi5pb1xuICogQ29weXJpZ2h0IChjKSAyMDIwIENKIFBhdG9pbG8gTGljZW5zZWQgdW5kZXIgdGhlIE1JVCBsaWNlbnNlXG4gKi9cblxuKiwqOmFmdGVyLCo6YmVmb3Jle2JveC1zaXppbmc6aW5oZXJpdH1odG1se2JveC1zaXppbmc6Ym9yZGVyLWJveDtmb250LXNpemU6NjIuNSV9Ym9keXtjb2xvcjojMDAwMDAwO2ZvbnQtZmFtaWx5OidIZWx2ZXRpY2EgTmV1ZScsICdIZWx2ZXRpY2EnLCAnQXJpYWwnLCBzYW5zLXNlcmlmO2ZvbnQtc2l6ZToxLjZlbTtmb250LXdlaWdodDozMDA7bGV0dGVyLXNwYWNpbmc6LjAxZW07bGluZS1oZWlnaHQ6MS42fWJsb2NrcXVvdGV7Ym9yZGVyLWxlZnQ6MC4zcmVtIHNvbGlkICNkMWQxZDE7bWFyZ2luLWxlZnQ6MDttYXJnaW4tcmlnaHQ6MDtwYWRkaW5nOjFyZW0gMS41cmVtfWJsb2NrcXVvdGUgKjpsYXN0LWNoaWxke21hcmdpbi1ib3R0b206MH0uYnV0dG9uLGJ1dHRvbixpbnB1dFt0eXBlPSdidXR0b24nXSxpbnB1dFt0eXBlPSdyZXNldCddLGlucHV0W3R5cGU9J3N1Ym1pdCdde2JhY2tncm91bmQtY29sb3I6IzAwNjlkOTtib3JkZXI6MC4xcmVtIHNvbGlkICMwMDY5ZDk7Ym9yZGVyLXJhZGl1czouNHJlbTtjb2xvcjojZmZmO2N1cnNvcjpwb2ludGVyO2Rpc3BsYXk6aW5saW5lLWJsb2NrO2ZvbnQtc2l6ZToxLjFyZW07Zm9udC13ZWlnaHQ6NzAwO2hlaWdodDozLjhyZW07bGV0dGVyLXNwYWNpbmc6LjFyZW07bGluZS1oZWlnaHQ6My44cmVtO3BhZGRpbmc6MCAzLjByZW07dGV4dC1hbGlnbjpjZW50ZXI7dGV4dC1kZWNvcmF0aW9uOm5vbmU7dGV4dC10cmFuc2Zvcm06dXBwZXJjYXNlO3doaXRlLXNwYWNlOm5vd3JhcH0uYnV0dG9uOmZvY3VzLC5idXR0b246aG92ZXIsYnV0dG9uOmZvY3VzLGJ1dHRvbjpob3ZlcixpbnB1dFt0eXBlPSdidXR0b24nXTpmb2N1cyxpbnB1dFt0eXBlPSdidXR0b24nXTpob3ZlcixpbnB1dFt0eXBlPSdyZXNldCddOmZvY3VzLGlucHV0W3R5cGU9J3Jlc2V0J106aG92ZXIsaW5wdXRbdHlwZT0nc3VibWl0J106Zm9jdXMsaW5wdXRbdHlwZT0nc3VibWl0J106aG92ZXJ7YmFja2dyb3VuZC1jb2xvcjojNjA2Yzc2O2JvcmRlci1jb2xvcjojNjA2Yzc2O2NvbG9yOiNmZmY7b3V0bGluZTowfS5idXR0b25bZGlzYWJsZWRdLGJ1dHRvbltkaXNhYmxlZF0saW5wdXRbdHlwZT0nYnV0dG9uJ11bZGlzYWJsZWRdLGlucHV0W3R5cGU9J3Jlc2V0J11bZGlzYWJsZWRdLGlucHV0W3R5cGU9J3N1Ym1pdCddW2Rpc2FibGVkXXtjdXJzb3I6ZGVmYXVsdDtvcGFjaXR5Oi41fS5idXR0b25bZGlzYWJsZWRdOmZvY3VzLC5idXR0b25bZGlzYWJsZWRdOmhvdmVyLGJ1dHRvbltkaXNhYmxlZF06Zm9jdXMsYnV0dG9uW2Rpc2FibGVkXTpob3ZlcixpbnB1dFt0eXBlPSdidXR0b24nXVtkaXNhYmxlZF06Zm9jdXMsaW5wdXRbdHlwZT0nYnV0dG9uJ11bZGlzYWJsZWRdOmhvdmVyLGlucHV0W3R5cGU9J3Jlc2V0J11bZGlzYWJsZWRdOmZvY3VzLGlucHV0W3R5cGU9J3Jlc2V0J11bZGlzYWJsZWRdOmhvdmVyLGlucHV0W3R5cGU9J3N1Ym1pdCddW2Rpc2FibGVkXTpmb2N1cyxpbnB1dFt0eXBlPSdzdWJtaXQnXVtkaXNhYmxlZF06aG92ZXJ7YmFja2dyb3VuZC1jb2xvcjojMDA2OWQ5O2JvcmRlci1jb2xvcjojMDA2OWQ5fS5idXR0b24uYnV0dG9uLW91dGxpbmUsYnV0dG9uLmJ1dHRvbi1vdXRsaW5lLGlucHV0W3R5cGU9J2J1dHRvbiddLmJ1dHRvbi1vdXRsaW5lLGlucHV0W3R5cGU9J3Jlc2V0J10uYnV0dG9uLW91dGxpbmUsaW5wdXRbdHlwZT0nc3VibWl0J10uYnV0dG9uLW91dGxpbmV7YmFja2dyb3VuZC1jb2xvcjp0cmFuc3BhcmVudDtjb2xvcjojMDA2OWQ5fS5idXR0b24uYnV0dG9uLW91dGxpbmU6Zm9jdXMsLmJ1dHRvbi5idXR0b24tb3V0bGluZTpob3ZlcixidXR0b24uYnV0dG9uLW91dGxpbmU6Zm9jdXMsYnV0dG9uLmJ1dHRvbi1vdXRsaW5lOmhvdmVyLGlucHV0W3R5cGU9J2J1dHRvbiddLmJ1dHRvbi1vdXRsaW5lOmZvY3VzLGlucHV0W3R5cGU9J2J1dHRvbiddLmJ1dHRvbi1vdXRsaW5lOmhvdmVyLGlucHV0W3R5cGU9J3Jlc2V0J10uYnV0dG9uLW91dGxpbmU6Zm9jdXMsaW5wdXRbdHlwZT0ncmVzZXQnXS5idXR0b24tb3V0bGluZTpob3ZlcixpbnB1dFt0eXBlPSdzdWJtaXQnXS5idXR0b24tb3V0bGluZTpmb2N1cyxpbnB1dFt0eXBlPSdzdWJtaXQnXS5idXR0b24tb3V0bGluZTpob3ZlcntiYWNrZ3JvdW5kLWNvbG9yOnRyYW5zcGFyZW50O2JvcmRlci1jb2xvcjojNjA2Yzc2O2NvbG9yOiM2MDZjNzZ9LmJ1dHRvbi5idXR0b24tb3V0bGluZVtkaXNhYmxlZF06Zm9jdXMsLmJ1dHRvbi5idXR0b24tb3V0bGluZVtkaXNhYmxlZF06aG92ZXIsYnV0dG9uLmJ1dHRvbi1vdXRsaW5lW2Rpc2FibGVkXTpmb2N1cyxidXR0b24uYnV0dG9uLW91dGxpbmVbZGlzYWJsZWRdOmhvdmVyLGlucHV0W3R5cGU9J2J1dHRvbiddLmJ1dHRvbi1vdXRsaW5lW2Rpc2FibGVkXTpmb2N1cyxpbnB1dFt0eXBlPSdidXR0b24nXS5idXR0b24tb3V0bGluZVtkaXNhYmxlZF06aG92ZXIsaW5wdXRbdHlwZT0ncmVzZXQnXS5idXR0b24tb3V0bGluZVtkaXNhYmxlZF06Zm9jdXMsaW5wdXRbdHlwZT0ncmVzZXQnXS5idXR0b24tb3V0bGluZVtkaXNhYmxlZF06aG92ZXIsaW5wdXRbdHlwZT0nc3VibWl0J10uYnV0dG9uLW91dGxpbmVbZGlzYWJsZWRdOmZvY3VzLGlucHV0W3R5cGU9J3N1Ym1pdCddLmJ1dHRvbi1vdXRsaW5lW2Rpc2FibGVkXTpob3Zlcntib3JkZXItY29sb3I6aW5oZXJpdDtjb2xvcjojMDA2OWQ5fS5idXR0b24uYnV0dG9uLWNsZWFyLGJ1dHRvbi5idXR0b24tY2xlYXIsaW5wdXRbdHlwZT0nYnV0dG9uJ10uYnV0dG9uLWNsZWFyLGlucHV0W3R5cGU9J3Jlc2V0J10uYnV0dG9uLWNsZWFyLGlucHV0W3R5cGU9J3N1Ym1pdCddLmJ1dHRvbi1jbGVhcntiYWNrZ3JvdW5kLWNvbG9yOnRyYW5zcGFyZW50O2JvcmRlci1jb2xvcjp0cmFuc3BhcmVudDtjb2xvcjojMDA2OWQ5fS5idXR0b24uYnV0dG9uLWNsZWFyOmZvY3VzLC5idXR0b24uYnV0dG9uLWNsZWFyOmhvdmVyLGJ1dHRvbi5idXR0b24tY2xlYXI6Zm9jdXMsYnV0dG9uLmJ1dHRvbi1jbGVhcjpob3ZlcixpbnB1dFt0eXBlPSdidXR0b24nXS5idXR0b24tY2xlYXI6Zm9jdXMsaW5wdXRbdHlwZT0nYnV0dG9uJ10uYnV0dG9uLWNsZWFyOmhvdmVyLGlucHV0W3R5cGU9J3Jlc2V0J10uYnV0dG9uLWNsZWFyOmZvY3VzLGlucHV0W3R5cGU9J3Jlc2V0J10uYnV0dG9uLWNsZWFyOmhvdmVyLGlucHV0W3R5cGU9J3N1Ym1pdCddLmJ1dHRvbi1jbGVhcjpmb2N1cyxpbnB1dFt0eXBlPSdzdWJtaXQnXS5idXR0b24tY2xlYXI6aG92ZXJ7YmFja2dyb3VuZC1jb2xvcjp0cmFuc3BhcmVudDtib3JkZXItY29sb3I6dHJhbnNwYXJlbnQ7Y29sb3I6IzYwNmM3Nn0uYnV0dG9uLmJ1dHRvbi1jbGVhcltkaXNhYmxlZF06Zm9jdXMsLmJ1dHRvbi5idXR0b24tY2xlYXJbZGlzYWJsZWRdOmhvdmVyLGJ1dHRvbi5idXR0b24tY2xlYXJbZGlzYWJsZWRdOmZvY3VzLGJ1dHRvbi5idXR0b24tY2xlYXJbZGlzYWJsZWRdOmhvdmVyLGlucHV0W3R5cGU9J2J1dHRvbiddLmJ1dHRvbi1jbGVhcltkaXNhYmxlZF06Zm9jdXMsaW5wdXRbdHlwZT0nYnV0dG9uJ10uYnV0dG9uLWNsZWFyW2Rpc2FibGVkXTpob3ZlcixpbnB1dFt0eXBlPSdyZXNldCddLmJ1dHRvbi1jbGVhcltkaXNhYmxlZF06Zm9jdXMsaW5wdXRbdHlwZT0ncmVzZXQnXS5idXR0b24tY2xlYXJbZGlzYWJsZWRdOmhvdmVyLGlucHV0W3R5cGU9J3N1Ym1pdCddLmJ1dHRvbi1jbGVhcltkaXNhYmxlZF06Zm9jdXMsaW5wdXRbdHlwZT0nc3VibWl0J10uYnV0dG9uLWNsZWFyW2Rpc2FibGVkXTpob3Zlcntjb2xvcjojMDA2OWQ5fWNvZGV7YmFja2dyb3VuZDojZjRmNWY2O2JvcmRlci1yYWRpdXM6LjRyZW07Zm9udC1zaXplOjg2JTttYXJnaW46MCAuMnJlbTtwYWRkaW5nOi4ycmVtIC41cmVtO3doaXRlLXNwYWNlOm5vd3JhcH1wcmV7YmFja2dyb3VuZDojZjRmNWY2O2JvcmRlci1sZWZ0OjAuM3JlbSBzb2xpZCAjMDA2OWQ5O292ZXJmbG93LXk6aGlkZGVufXByZT5jb2Rle2JvcmRlci1yYWRpdXM6MDtkaXNwbGF5OmJsb2NrO3BhZGRpbmc6MXJlbSAxLjVyZW07d2hpdGUtc3BhY2U6cHJlfWhye2JvcmRlcjowO2JvcmRlci10b3A6MC4xcmVtIHNvbGlkICNmNGY1ZjY7bWFyZ2luOjMuMHJlbSAwfWlucHV0W3R5cGU9J2NvbG9yJ10saW5wdXRbdHlwZT0nZGF0ZSddLGlucHV0W3R5cGU9J2RhdGV0aW1lJ10saW5wdXRbdHlwZT0nZGF0ZXRpbWUtbG9jYWwnXSxpbnB1dFt0eXBlPSdlbWFpbCddLGlucHV0W3R5cGU9J21vbnRoJ10saW5wdXRbdHlwZT0nbnVtYmVyJ10saW5wdXRbdHlwZT0ncGFzc3dvcmQnXSxpbnB1dFt0eXBlPSdzZWFyY2gnXSxpbnB1dFt0eXBlPSd0ZWwnXSxpbnB1dFt0eXBlPSd0ZXh0J10saW5wdXRbdHlwZT0ndXJsJ10saW5wdXRbdHlwZT0nd2VlayddLGlucHV0Om5vdChbdHlwZV0pLHRleHRhcmVhLHNlbGVjdHstd2Via2l0LWFwcGVhcmFuY2U6bm9uZTtiYWNrZ3JvdW5kLWNvbG9yOnRyYW5zcGFyZW50O2JvcmRlcjowLjFyZW0gc29saWQgI2QxZDFkMTtib3JkZXItcmFkaXVzOi40cmVtO2JveC1zaGFkb3c6bm9uZTtib3gtc2l6aW5nOmluaGVyaXQ7aGVpZ2h0OjMuOHJlbTtwYWRkaW5nOi42cmVtIDEuMHJlbSAuN3JlbTt3aWR0aDoxMDAlfWlucHV0W3R5cGU9J2NvbG9yJ106Zm9jdXMsaW5wdXRbdHlwZT0nZGF0ZSddOmZvY3VzLGlucHV0W3R5cGU9J2RhdGV0aW1lJ106Zm9jdXMsaW5wdXRbdHlwZT0nZGF0ZXRpbWUtbG9jYWwnXTpmb2N1cyxpbnB1dFt0eXBlPSdlbWFpbCddOmZvY3VzLGlucHV0W3R5cGU9J21vbnRoJ106Zm9jdXMsaW5wdXRbdHlwZT0nbnVtYmVyJ106Zm9jdXMsaW5wdXRbdHlwZT0ncGFzc3dvcmQnXTpmb2N1cyxpbnB1dFt0eXBlPSdzZWFyY2gnXTpmb2N1cyxpbnB1dFt0eXBlPSd0ZWwnXTpmb2N1cyxpbnB1dFt0eXBlPSd0ZXh0J106Zm9jdXMsaW5wdXRbdHlwZT0ndXJsJ106Zm9jdXMsaW5wdXRbdHlwZT0nd2VlayddOmZvY3VzLGlucHV0Om5vdChbdHlwZV0pOmZvY3VzLHRleHRhcmVhOmZvY3VzLHNlbGVjdDpmb2N1c3tib3JkZXItY29sb3I6IzAwNjlkOTtvdXRsaW5lOjB9c2VsZWN0e2JhY2tncm91bmQ6dXJsKCdkYXRhOmltYWdlL3N2Zyt4bWw7dXRmOCw8c3ZnIHhtbG5zPVwiaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmdcIiB2aWV3Qm94PVwiMCAwIDMwIDhcIiB3aWR0aD1cIjMwXCI+PHBhdGggZmlsbD1cIiUyM2QxZDFkMVwiIGQ9XCJNMCwwbDYsOGw2LThcIi8+PC9zdmc+JykgY2VudGVyIHJpZ2h0IG5vLXJlcGVhdDtwYWRkaW5nLXJpZ2h0OjMuMHJlbX1zZWxlY3Q6Zm9jdXN7YmFja2dyb3VuZC1pbWFnZTp1cmwoJ2RhdGE6aW1hZ2Uvc3ZnK3htbDt1dGY4LDxzdmcgeG1sbnM9XCJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2Z1wiIHZpZXdCb3g9XCIwIDAgMzAgOFwiIHdpZHRoPVwiMzBcIj48cGF0aCBmaWxsPVwiJTIzMDA2OWQ5XCIgZD1cIk0wLDBsNiw4bDYtOFwiLz48L3N2Zz4nKX1zZWxlY3RbbXVsdGlwbGVde2JhY2tncm91bmQ6bm9uZTtoZWlnaHQ6YXV0b310ZXh0YXJlYXttaW4taGVpZ2h0OjYuNXJlbX1sYWJlbCxsZWdlbmR7ZGlzcGxheTpibG9jaztmb250LXNpemU6MS42cmVtO2ZvbnQtd2VpZ2h0OjcwMDttYXJnaW4tYm90dG9tOi41cmVtfWZpZWxkc2V0e2JvcmRlci13aWR0aDowO3BhZGRpbmc6MH1pbnB1dFt0eXBlPSdjaGVja2JveCddLGlucHV0W3R5cGU9J3JhZGlvJ117ZGlzcGxheTppbmxpbmV9LmxhYmVsLWlubGluZXtkaXNwbGF5OmlubGluZS1ibG9jaztmb250LXdlaWdodDpub3JtYWw7bWFyZ2luLWxlZnQ6LjVyZW19LmNvbnRhaW5lcnttYXJnaW46MCBhdXRvO21heC13aWR0aDoxMTIuMHJlbTtwYWRkaW5nOjAgMi4wcmVtO3Bvc2l0aW9uOnJlbGF0aXZlO3dpZHRoOjEwMCV9LnJvd3tkaXNwbGF5OmZsZXg7ZmxleC1kaXJlY3Rpb246Y29sdW1uO3BhZGRpbmc6MDt3aWR0aDoxMDAlfS5yb3cucm93LW5vLXBhZGRpbmd7cGFkZGluZzowfS5yb3cucm93LW5vLXBhZGRpbmc+LmNvbHVtbntwYWRkaW5nOjB9LnJvdy5yb3ctd3JhcHtmbGV4LXdyYXA6d3JhcH0ucm93LnJvdy10b3B7YWxpZ24taXRlbXM6ZmxleC1zdGFydH0ucm93LnJvdy1ib3R0b217YWxpZ24taXRlbXM6ZmxleC1lbmR9LnJvdy5yb3ctY2VudGVye2FsaWduLWl0ZW1zOmNlbnRlcn0ucm93LnJvdy1zdHJldGNoe2FsaWduLWl0ZW1zOnN0cmV0Y2h9LnJvdy5yb3ctYmFzZWxpbmV7YWxpZ24taXRlbXM6YmFzZWxpbmV9LnJvdyAuY29sdW1ue2Rpc3BsYXk6YmxvY2s7ZmxleDoxIDEgYXV0bzttYXJnaW4tbGVmdDowO21heC13aWR0aDoxMDAlO3dpZHRoOjEwMCV9LnJvdyAuY29sdW1uLmNvbHVtbi1vZmZzZXQtMTB7bWFyZ2luLWxlZnQ6MTAlfS5yb3cgLmNvbHVtbi5jb2x1bW4tb2Zmc2V0LTIwe21hcmdpbi1sZWZ0OjIwJX0ucm93IC5jb2x1bW4uY29sdW1uLW9mZnNldC0yNXttYXJnaW4tbGVmdDoyNSV9LnJvdyAuY29sdW1uLmNvbHVtbi1vZmZzZXQtMzMsLnJvdyAuY29sdW1uLmNvbHVtbi1vZmZzZXQtMzR7bWFyZ2luLWxlZnQ6MzMuMzMzMyV9LnJvdyAuY29sdW1uLmNvbHVtbi1vZmZzZXQtNDB7bWFyZ2luLWxlZnQ6NDAlfS5yb3cgLmNvbHVtbi5jb2x1bW4tb2Zmc2V0LTUwe21hcmdpbi1sZWZ0OjUwJX0ucm93IC5jb2x1bW4uY29sdW1uLW9mZnNldC02MHttYXJnaW4tbGVmdDo2MCV9LnJvdyAuY29sdW1uLmNvbHVtbi1vZmZzZXQtNjYsLnJvdyAuY29sdW1uLmNvbHVtbi1vZmZzZXQtNjd7bWFyZ2luLWxlZnQ6NjYuNjY2NiV9LnJvdyAuY29sdW1uLmNvbHVtbi1vZmZzZXQtNzV7bWFyZ2luLWxlZnQ6NzUlfS5yb3cgLmNvbHVtbi5jb2x1bW4tb2Zmc2V0LTgwe21hcmdpbi1sZWZ0OjgwJX0ucm93IC5jb2x1bW4uY29sdW1uLW9mZnNldC05MHttYXJnaW4tbGVmdDo5MCV9LnJvdyAuY29sdW1uLmNvbHVtbi0xMHtmbGV4OjAgMCAxMCU7bWF4LXdpZHRoOjEwJX0ucm93IC5jb2x1bW4uY29sdW1uLTIwe2ZsZXg6MCAwIDIwJTttYXgtd2lkdGg6MjAlfS5yb3cgLmNvbHVtbi5jb2x1bW4tMjV7ZmxleDowIDAgMjUlO21heC13aWR0aDoyNSV9LnJvdyAuY29sdW1uLmNvbHVtbi0zMywucm93IC5jb2x1bW4uY29sdW1uLTM0e2ZsZXg6MCAwIDMzLjMzMzMlO21heC13aWR0aDozMy4zMzMzJX0ucm93IC5jb2x1bW4uY29sdW1uLTQwe2ZsZXg6MCAwIDQwJTttYXgtd2lkdGg6NDAlfS5yb3cgLmNvbHVtbi5jb2x1bW4tNTB7ZmxleDowIDAgNTAlO21heC13aWR0aDo1MCV9LnJvdyAuY29sdW1uLmNvbHVtbi02MHtmbGV4OjAgMCA2MCU7bWF4LXdpZHRoOjYwJX0ucm93IC5jb2x1bW4uY29sdW1uLTY2LC5yb3cgLmNvbHVtbi5jb2x1bW4tNjd7ZmxleDowIDAgNjYuNjY2NiU7bWF4LXdpZHRoOjY2LjY2NjYlfS5yb3cgLmNvbHVtbi5jb2x1bW4tNzV7ZmxleDowIDAgNzUlO21heC13aWR0aDo3NSV9LnJvdyAuY29sdW1uLmNvbHVtbi04MHtmbGV4OjAgMCA4MCU7bWF4LXdpZHRoOjgwJX0ucm93IC5jb2x1bW4uY29sdW1uLTkwe2ZsZXg6MCAwIDkwJTttYXgtd2lkdGg6OTAlfS5yb3cgLmNvbHVtbiAuY29sdW1uLXRvcHthbGlnbi1zZWxmOmZsZXgtc3RhcnR9LnJvdyAuY29sdW1uIC5jb2x1bW4tYm90dG9te2FsaWduLXNlbGY6ZmxleC1lbmR9LnJvdyAuY29sdW1uIC5jb2x1bW4tY2VudGVye2FsaWduLXNlbGY6Y2VudGVyfUBtZWRpYSAobWluLXdpZHRoOiA0MHJlbSl7LnJvd3tmbGV4LWRpcmVjdGlvbjpyb3c7bWFyZ2luLWxlZnQ6LTEuMHJlbTt3aWR0aDpjYWxjKDEwMCUgKyAyLjByZW0pfS5yb3cgLmNvbHVtbnttYXJnaW4tYm90dG9tOmluaGVyaXQ7cGFkZGluZzowIDEuMHJlbX19YXtjb2xvcjojMDA2OWQ5O3RleHQtZGVjb3JhdGlvbjpub25lfWE6Zm9jdXMsYTpob3Zlcntjb2xvcjojNjA2Yzc2fWRsLG9sLHVse2xpc3Qtc3R5bGU6bm9uZTttYXJnaW4tdG9wOjA7cGFkZGluZy1sZWZ0OjB9ZGwgZGwsZGwgb2wsZGwgdWwsb2wgZGwsb2wgb2wsb2wgdWwsdWwgZGwsdWwgb2wsdWwgdWx7Zm9udC1zaXplOjkwJTttYXJnaW46MS41cmVtIDAgMS41cmVtIDMuMHJlbX1vbHtsaXN0LXN0eWxlOmRlY2ltYWwgaW5zaWRlfXVse2xpc3Qtc3R5bGU6Y2lyY2xlIGluc2lkZX0uYnV0dG9uLGJ1dHRvbixkZCxkdCxsaXttYXJnaW4tYm90dG9tOjEuMHJlbX1maWVsZHNldCxpbnB1dCxzZWxlY3QsdGV4dGFyZWF7bWFyZ2luLWJvdHRvbToxLjVyZW19YmxvY2txdW90ZSxkbCxmaWd1cmUsZm9ybSxvbCxwLHByZSx0YWJsZSx1bHttYXJnaW4tYm90dG9tOjIuNXJlbX10YWJsZXtib3JkZXItc3BhY2luZzowO2Rpc3BsYXk6YmxvY2s7b3ZlcmZsb3cteDphdXRvO3RleHQtYWxpZ246bGVmdDt3aWR0aDoxMDAlfXRkLHRoe2JvcmRlci1ib3R0b206MC4xcmVtIHNvbGlkICNlMWUxZTE7cGFkZGluZzoxLjJyZW0gMS41cmVtfXRkOmZpcnN0LWNoaWxkLHRoOmZpcnN0LWNoaWxke3BhZGRpbmctbGVmdDowfXRkOmxhc3QtY2hpbGQsdGg6bGFzdC1jaGlsZHtwYWRkaW5nLXJpZ2h0OjB9QG1lZGlhIChtaW4td2lkdGg6IDQwcmVtKXt0YWJsZXtkaXNwbGF5OnRhYmxlO292ZXJmbG93LXg6aW5pdGlhbH19YixzdHJvbmd7Zm9udC13ZWlnaHQ6Ym9sZH1we21hcmdpbi10b3A6MH1oMSxoMixoMyxoNCxoNSxoNntmb250LXdlaWdodDozMDA7bGV0dGVyLXNwYWNpbmc6LS4xcmVtO21hcmdpbi1ib3R0b206Mi4wcmVtO21hcmdpbi10b3A6MH1oMXtmb250LXNpemU6NC42cmVtO2xpbmUtaGVpZ2h0OjEuMn1oMntmb250LXNpemU6My42cmVtO2xpbmUtaGVpZ2h0OjEuMjV9aDN7Zm9udC1zaXplOjIuOHJlbTtsaW5lLWhlaWdodDoxLjN9aDR7Zm9udC1zaXplOjIuMnJlbTtsZXR0ZXItc3BhY2luZzotLjA4cmVtO2xpbmUtaGVpZ2h0OjEuMzV9aDV7Zm9udC1zaXplOjEuOHJlbTtsZXR0ZXItc3BhY2luZzotLjA1cmVtO2xpbmUtaGVpZ2h0OjEuNX1oNntmb250LXNpemU6MS42cmVtO2xldHRlci1zcGFjaW5nOjA7bGluZS1oZWlnaHQ6MS40fWltZ3ttYXgtd2lkdGg6MTAwJX0uY2xlYXJmaXg6YWZ0ZXJ7Y2xlYXI6Ym90aDtjb250ZW50OicgJztkaXNwbGF5OnRhYmxlfS5mbG9hdC1sZWZ0e2Zsb2F0OmxlZnR9LmZsb2F0LXJpZ2h0e2Zsb2F0OnJpZ2h0fVxuXG4vKiBHZW5lcmFsIHN0eWxlICovXG5oMXtmb250LXNpemU6IDMuNnJlbTsgbGluZS1oZWlnaHQ6IDEuMjV9XG5oMntmb250LXNpemU6IDIuOHJlbTsgbGluZS1oZWlnaHQ6IDEuM31cbmgze2ZvbnQtc2l6ZTogMi4ycmVtOyBsZXR0ZXItc3BhY2luZzogLS4wOHJlbTsgbGluZS1oZWlnaHQ6IDEuMzV9XG5oNHtmb250LXNpemU6IDEuOHJlbTsgbGV0dGVyLXNwYWNpbmc6IC0uMDVyZW07IGxpbmUtaGVpZ2h0OiAxLjV9XG5oNXtmb250LXNpemU6IDEuNnJlbTsgbGV0dGVyLXNwYWNpbmc6IDA7IGxpbmUtaGVpZ2h0OiAxLjR9XG5oNntmb250LXNpemU6IDEuNHJlbTsgbGV0dGVyLXNwYWNpbmc6IDA7IGxpbmUtaGVpZ2h0OiAxLjJ9XG5wcmV7cGFkZGluZzogMWVtO31cblxuLmNvbnRhaW5lcntcbiAgbWFyZ2luOiAwIGF1dG87XG4gIG1heC13aWR0aDogODAuMHJlbTtcbiAgcGFkZGluZzogMCAyLjByZW07XG4gIHBvc2l0aW9uOiByZWxhdGl2ZTtcbiAgd2lkdGg6IDEwMCVcbn1cbnNlbGVjdCB7XG4gIHdpZHRoOiBhdXRvO1xufVxuXG4vKiBQaG9lbml4IHByb21vIGFuZCBsb2dvICovXG4ucGh4LWhlcm8ge1xuICB0ZXh0LWFsaWduOiBjZW50ZXI7XG4gIGJvcmRlci1ib3R0b206IDFweCBzb2xpZCAjZTNlM2UzO1xuICBiYWNrZ3JvdW5kOiAjZWVlO1xuICBib3JkZXItcmFkaXVzOiA2cHg7XG4gIHBhZGRpbmc6IDNlbSAzZW0gMWVtO1xuICBtYXJnaW4tYm90dG9tOiAzcmVtO1xuICBmb250LXdlaWdodDogMjAwO1xuICBmb250LXNpemU6IDEyMCU7XG59XG4ucGh4LWhlcm8gaW5wdXQge1xuICBiYWNrZ3JvdW5kOiAjZmZmZmZmO1xufVxuLnBoeC1sb2dvIHtcbiAgbWluLXdpZHRoOiAzMDBweDtcbiAgbWFyZ2luOiAxcmVtO1xuICBkaXNwbGF5OiBibG9jaztcbn1cbi5waHgtbG9nbyBpbWcge1xuICB3aWR0aDogYXV0bztcbiAgZGlzcGxheTogYmxvY2s7XG59XG5cbi8qIEhlYWRlcnMgKi9cbmhlYWRlciB7XG4gIHdpZHRoOiAxMDAlO1xuICBiYWNrZ3JvdW5kOiAjZmRmZGZkO1xuICBib3JkZXItYm90dG9tOiAxcHggc29saWQgI2VhZWFlYTtcbiAgbWFyZ2luLWJvdHRvbTogMnJlbTtcbn1cbmhlYWRlciBzZWN0aW9uIHtcbiAgYWxpZ24taXRlbXM6IGNlbnRlcjtcbiAgZGlzcGxheTogZmxleDtcbiAgZmxleC1kaXJlY3Rpb246IGNvbHVtbjtcbiAganVzdGlmeS1jb250ZW50OiBzcGFjZS1iZXR3ZWVuO1xufVxuaGVhZGVyIHNlY3Rpb24gOmZpcnN0LWNoaWxkIHtcbiAgb3JkZXI6IDI7XG59XG5oZWFkZXIgc2VjdGlvbiA6bGFzdC1jaGlsZCB7XG4gIG9yZGVyOiAxO1xufVxuaGVhZGVyIG5hdiB1bCxcbmhlYWRlciBuYXYgbGkge1xuICBtYXJnaW46IDA7XG4gIHBhZGRpbmc6IDA7XG4gIGRpc3BsYXk6IGJsb2NrO1xuICB0ZXh0LWFsaWduOiByaWdodDtcbiAgd2hpdGUtc3BhY2U6IG5vd3JhcDtcbn1cbmhlYWRlciBuYXYgdWwge1xuICBtYXJnaW46IDFyZW07XG4gIG1hcmdpbi10b3A6IDA7XG59XG5oZWFkZXIgbmF2IGEge1xuICBkaXNwbGF5OiBibG9jaztcbn1cblxuQG1lZGlhIChtaW4td2lkdGg6IDQwLjByZW0pIHsgLyogU21hbGwgZGV2aWNlcyAobGFuZHNjYXBlIHBob25lcywgNTc2cHggYW5kIHVwKSAqL1xuICBoZWFkZXIgc2VjdGlvbiB7XG4gICAgZmxleC1kaXJlY3Rpb246IHJvdztcbiAgfVxuICBoZWFkZXIgbmF2IHVsIHtcbiAgICBtYXJnaW46IDFyZW07XG4gIH1cbiAgLnBoeC1sb2dvIHtcbiAgICBmbGV4LWJhc2lzOiA1MjdweDtcbiAgICBtYXJnaW46IDJyZW0gMXJlbTtcbiAgfVxufVxuIiwgIi8qIFRoaXMgZmlsZSBpcyBmb3IgeW91ciBtYWluIGFwcGxpY2F0aW9uIENTUyAqL1xuQGltcG9ydCBcIi4vcGhvZW5peC5jc3NcIjtcblxuLyogQWxlcnRzIGFuZCBmb3JtIGVycm9ycyB1c2VkIGJ5IHBoeC5uZXcgKi9cbi5hbGVydCB7XG4gIHBhZGRpbmc6IDE1cHg7XG4gIG1hcmdpbi1ib3R0b206IDIwcHg7XG4gIGJvcmRlcjogMXB4IHNvbGlkIHRyYW5zcGFyZW50O1xuICBib3JkZXItcmFkaXVzOiA0cHg7XG59XG4uYWxlcnQtaW5mbyB7XG4gIGNvbG9yOiAjMzE3MDhmO1xuICBiYWNrZ3JvdW5kLWNvbG9yOiAjZDllZGY3O1xuICBib3JkZXItY29sb3I6ICNiY2U4ZjE7XG59XG4uYWxlcnQtd2FybmluZyB7XG4gIGNvbG9yOiAjOGE2ZDNiO1xuICBiYWNrZ3JvdW5kLWNvbG9yOiAjZmNmOGUzO1xuICBib3JkZXItY29sb3I6ICNmYWViY2M7XG59XG4uYWxlcnQtZGFuZ2VyIHtcbiAgY29sb3I6ICNhOTQ0NDI7XG4gIGJhY2tncm91bmQtY29sb3I6ICNmMmRlZGU7XG4gIGJvcmRlci1jb2xvcjogI2ViY2NkMTtcbn1cbi5hbGVydCBwIHtcbiAgbWFyZ2luLWJvdHRvbTogMDtcbn1cbi5hbGVydDplbXB0eSB7XG4gIGRpc3BsYXk6IG5vbmU7XG59XG4uaW52YWxpZC1mZWVkYmFjayB7XG4gIGNvbG9yOiAjYTk0NDQyO1xuICBkaXNwbGF5OiBibG9jaztcbiAgbWFyZ2luOiAtMXJlbSAwIDJyZW07XG59XG5cbi8qIExpdmVWaWV3IHNwZWNpZmljIGNsYXNzZXMgZm9yIHlvdXIgY3VzdG9taXphdGlvbiAqL1xuLnBoeC1uby1mZWVkYmFjay5pbnZhbGlkLWZlZWRiYWNrLFxuLnBoeC1uby1mZWVkYmFjayAuaW52YWxpZC1mZWVkYmFjayB7XG4gIGRpc3BsYXk6IG5vbmU7XG59XG5cbi5waHgtY2xpY2stbG9hZGluZyB7XG4gIG9wYWNpdHk6IDAuNTtcbiAgdHJhbnNpdGlvbjogb3BhY2l0eSAxcyBlYXNlLW91dDtcbn1cblxuLnBoeC1sb2FkaW5ne1xuICBjdXJzb3I6IHdhaXQ7XG59XG5cbi5waHgtbW9kYWwge1xuICBvcGFjaXR5OiAxIWltcG9ydGFudDtcbiAgcG9zaXRpb246IGZpeGVkO1xuICB6LWluZGV4OiAxO1xuICBsZWZ0OiAwO1xuICB0b3A6IDA7XG4gIHdpZHRoOiAxMDAlO1xuICBoZWlnaHQ6IDEwMCU7XG4gIG92ZXJmbG93OiBhdXRvO1xuICBiYWNrZ3JvdW5kLWNvbG9yOiByZ2JhKDAsMCwwLDAuNCk7XG59XG5cbi5waHgtbW9kYWwtY29udGVudCB7XG4gIGJhY2tncm91bmQtY29sb3I6ICNmZWZlZmU7XG4gIG1hcmdpbjogMTV2aCBhdXRvO1xuICBwYWRkaW5nOiAyMHB4O1xuICBib3JkZXI6IDFweCBzb2xpZCAjODg4O1xuICB3aWR0aDogODAlO1xufVxuXG4ucGh4LW1vZGFsLWNsb3NlIHtcbiAgY29sb3I6ICNhYWE7XG4gIGZsb2F0OiByaWdodDtcbiAgZm9udC1zaXplOiAyOHB4O1xuICBmb250LXdlaWdodDogYm9sZDtcbn1cblxuLnBoeC1tb2RhbC1jbG9zZTpob3Zlcixcbi5waHgtbW9kYWwtY2xvc2U6Zm9jdXMge1xuICBjb2xvcjogYmxhY2s7XG4gIHRleHQtZGVjb3JhdGlvbjogbm9uZTtcbiAgY3Vyc29yOiBwb2ludGVyO1xufVxuXG4uZmFkZS1pbi1zY2FsZSB7XG4gIGFuaW1hdGlvbjogMC4ycyBlYXNlLWluIDBzIG5vcm1hbCBmb3J3YXJkcyAxIGZhZGUtaW4tc2NhbGUta2V5cztcbn1cblxuLmZhZGUtb3V0LXNjYWxlIHtcbiAgYW5pbWF0aW9uOiAwLjJzIGVhc2Utb3V0IDBzIG5vcm1hbCBmb3J3YXJkcyAxIGZhZGUtb3V0LXNjYWxlLWtleXM7XG59XG5cbi5mYWRlLWluIHtcbiAgYW5pbWF0aW9uOiAwLjJzIGVhc2Utb3V0IDBzIG5vcm1hbCBmb3J3YXJkcyAxIGZhZGUtaW4ta2V5cztcbn1cbi5mYWRlLW91dCB7XG4gIGFuaW1hdGlvbjogMC4ycyBlYXNlLW91dCAwcyBub3JtYWwgZm9yd2FyZHMgMSBmYWRlLW91dC1rZXlzO1xufVxuXG5Aa2V5ZnJhbWVzIGZhZGUtaW4tc2NhbGUta2V5c3tcbiAgMCUgeyBzY2FsZTogMC45NTsgb3BhY2l0eTogMDsgfVxuICAxMDAlIHsgc2NhbGU6IDEuMDsgb3BhY2l0eTogMTsgfVxufVxuXG5Aa2V5ZnJhbWVzIGZhZGUtb3V0LXNjYWxlLWtleXN7XG4gIDAlIHsgc2NhbGU6IDEuMDsgb3BhY2l0eTogMTsgfVxuICAxMDAlIHsgc2NhbGU6IDAuOTU7IG9wYWNpdHk6IDA7IH1cbn1cblxuQGtleWZyYW1lcyBmYWRlLWluLWtleXN7XG4gIDAlIHsgb3BhY2l0eTogMDsgfVxuICAxMDAlIHsgb3BhY2l0eTogMTsgfVxufVxuXG5Aa2V5ZnJhbWVzIGZhZGUtb3V0LWtleXN7XG4gIDAlIHsgb3BhY2l0eTogMTsgfVxuICAxMDAlIHsgb3BhY2l0eTogMDsgfVxufVxuIl0sCiAgIm1hcHBpbmdzIjogIjtBQVFBO0FBQUE7QUFBQTtBQUFtQjtBQUFBO0FBQW1CO0FBQUs7QUFBc0I7QUFBQTtBQUFnQjtBQUFLO0FBQWM7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUErRDtBQUFnQjtBQUFnQjtBQUFxQjtBQUFBO0FBQWdCO0FBQVc7QUFBaUM7QUFBYztBQUFlO0FBQUE7QUFBb0I7QUFBd0I7QUFBQTtBQUFnQjtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQTZFO0FBQXlCO0FBQTRCO0FBQW9CO0FBQVc7QUFBZTtBQUFxQjtBQUFpQjtBQUFnQjtBQUFjO0FBQXFCO0FBQW1CO0FBQWlCO0FBQWtCO0FBQXFCO0FBQXlCO0FBQUE7QUFBbUI7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBc047QUFBeUI7QUFBcUI7QUFBVztBQUFBO0FBQVU7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUErSDtBQUFlO0FBQUE7QUFBVztBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUEwVDtBQUF5QjtBQUFBO0FBQXFCO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBd0o7QUFBNkI7QUFBQTtBQUFjO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQTRXO0FBQTZCO0FBQXFCO0FBQUE7QUFBYztBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFnZDtBQUFxQjtBQUFBO0FBQWM7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUE4STtBQUE2QjtBQUF5QjtBQUFBO0FBQWM7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBd1Y7QUFBNkI7QUFBeUI7QUFBQTtBQUFjO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQTRiO0FBQUE7QUFBYztBQUFLO0FBQW1CO0FBQW9CO0FBQWM7QUFBZTtBQUFvQjtBQUFBO0FBQW1CO0FBQUk7QUFBbUI7QUFBaUM7QUFBQTtBQUFrQjtBQUFTO0FBQWdCO0FBQWM7QUFBb0I7QUFBQTtBQUFnQjtBQUFHO0FBQVM7QUFBZ0M7QUFBQTtBQUFnQjtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFnVDtBQUF3QjtBQUE2QjtBQUE0QjtBQUFvQjtBQUFnQjtBQUFtQjtBQUFjO0FBQTJCO0FBQUE7QUFBVztBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFnWjtBQUFxQjtBQUFBO0FBQVU7QUFBTztBQUF1TDtBQUFBO0FBQXFCO0FBQWE7QUFBQTtBQUFzSztBQUFpQjtBQUFnQjtBQUFBO0FBQVk7QUFBUztBQUFBO0FBQWtCO0FBQUE7QUFBYTtBQUFjO0FBQWlCO0FBQWdCO0FBQUE7QUFBb0I7QUFBUztBQUFlO0FBQUE7QUFBVTtBQUFBO0FBQTJDO0FBQUE7QUFBZTtBQUFjO0FBQXFCO0FBQW1CO0FBQUE7QUFBa0I7QUFBVztBQUFjO0FBQW1CO0FBQWlCO0FBQWtCO0FBQUE7QUFBVztBQUFLO0FBQWE7QUFBc0I7QUFBVTtBQUFBO0FBQVc7QUFBb0I7QUFBQTtBQUFVO0FBQTRCO0FBQUE7QUFBVTtBQUFjO0FBQUE7QUFBZTtBQUFhO0FBQUE7QUFBdUI7QUFBZ0I7QUFBQTtBQUFxQjtBQUFnQjtBQUFBO0FBQW1CO0FBQWlCO0FBQUE7QUFBb0I7QUFBa0I7QUFBQTtBQUFxQjtBQUFhO0FBQWM7QUFBYztBQUFjO0FBQWU7QUFBQTtBQUFXO0FBQThCO0FBQUE7QUFBZ0I7QUFBOEI7QUFBQTtBQUFnQjtBQUE4QjtBQUFBO0FBQWdCO0FBQUE7QUFBNEQ7QUFBQTtBQUFxQjtBQUE4QjtBQUFBO0FBQWdCO0FBQThCO0FBQUE7QUFBZ0I7QUFBOEI7QUFBQTtBQUFnQjtBQUFBO0FBQTREO0FBQUE7QUFBcUI7QUFBOEI7QUFBQTtBQUFnQjtBQUE4QjtBQUFBO0FBQWdCO0FBQThCO0FBQUE7QUFBZ0I7QUFBdUI7QUFBYTtBQUFBO0FBQWM7QUFBdUI7QUFBYTtBQUFBO0FBQWM7QUFBdUI7QUFBYTtBQUFBO0FBQWM7QUFBQTtBQUE4QztBQUFrQjtBQUFBO0FBQW1CO0FBQXVCO0FBQWE7QUFBQTtBQUFjO0FBQXVCO0FBQWE7QUFBQTtBQUFjO0FBQXVCO0FBQWE7QUFBQTtBQUFjO0FBQUE7QUFBOEM7QUFBa0I7QUFBQTtBQUFtQjtBQUF1QjtBQUFhO0FBQUE7QUFBYztBQUF1QjtBQUFhO0FBQUE7QUFBYztBQUF1QjtBQUFhO0FBQUE7QUFBYztBQUF5QjtBQUFBO0FBQXNCO0FBQTRCO0FBQUE7QUFBb0I7QUFBNEI7QUFBQTtBQUFrQjtBQUEwQjtBQUFLO0FBQW1CO0FBQW9CO0FBQUE7QUFBMEI7QUFBYTtBQUFzQjtBQUFBO0FBQUE7QUFBa0I7QUFBRTtBQUFjO0FBQUE7QUFBcUI7QUFBQTtBQUFnQjtBQUFBO0FBQWM7QUFBQTtBQUFBO0FBQVM7QUFBZ0I7QUFBYTtBQUFBO0FBQWU7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQXNEO0FBQWM7QUFBQTtBQUE4QjtBQUFHO0FBQUE7QUFBMEI7QUFBRztBQUFBO0FBQXlCO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBd0I7QUFBQTtBQUFxQjtBQUFBO0FBQUE7QUFBQTtBQUErQjtBQUFBO0FBQXFCO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUE0QztBQUFBO0FBQXFCO0FBQU07QUFBaUI7QUFBYztBQUFnQjtBQUFnQjtBQUFBO0FBQVc7QUFBQTtBQUFNO0FBQW1DO0FBQUE7QUFBc0I7QUFBQTtBQUE4QjtBQUFBO0FBQWU7QUFBQTtBQUE0QjtBQUFBO0FBQWdCO0FBQTBCO0FBQU07QUFBYztBQUFBO0FBQUE7QUFBb0I7QUFBQTtBQUFTO0FBQUE7QUFBaUI7QUFBRTtBQUFBO0FBQWE7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQWtCO0FBQWdCO0FBQXNCO0FBQXFCO0FBQUE7QUFBYTtBQUFHO0FBQWlCO0FBQUE7QUFBZ0I7QUFBRztBQUFpQjtBQUFBO0FBQWlCO0FBQUc7QUFBaUI7QUFBQTtBQUFnQjtBQUFHO0FBQWlCO0FBQXVCO0FBQUE7QUFBaUI7QUFBRztBQUFpQjtBQUF1QjtBQUFBO0FBQWdCO0FBQUc7QUFBaUI7QUFBaUI7QUFBQTtBQUFnQjtBQUFJO0FBQUE7QUFBZTtBQUFnQjtBQUFXO0FBQVk7QUFBQTtBQUFjO0FBQVk7QUFBQTtBQUFXO0FBQWE7QUFBQTtBQUdqdlI7QUFBRztBQUFtQjtBQUFBO0FBQ3RCO0FBQUc7QUFBbUI7QUFBQTtBQUN0QjtBQUFHO0FBQW1CO0FBQXlCO0FBQUE7QUFDL0M7QUFBRztBQUFtQjtBQUF5QjtBQUFBO0FBQy9DO0FBQUc7QUFBbUI7QUFBbUI7QUFBQTtBQUN6QztBQUFHO0FBQW1CO0FBQW1CO0FBQUE7QUFDekM7QUFBSTtBQUFBO0FBRUo7QUFDRTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQUE7QUFFRjtBQUNFO0FBQUE7QUFJRjtBQUNFO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFBQTtBQUVGO0FBQ0U7QUFBQTtBQUVGO0FBQ0U7QUFDQTtBQUNBO0FBQUE7QUFFRjtBQUNFO0FBQ0E7QUFBQTtBQUlGO0FBQ0U7QUFDQTtBQUNBO0FBQ0E7QUFBQTtBQUVGO0FBQ0U7QUFDQTtBQUNBO0FBQ0E7QUFBQTtBQUVGO0FBQ0U7QUFBQTtBQUVGO0FBQ0U7QUFBQTtBQUVGO0FBQUE7QUFFRTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQUE7QUFFRjtBQUNFO0FBQ0E7QUFBQTtBQUVGO0FBQ0U7QUFBQTtBQUdGO0FBQ0U7QUFDRTtBQUFBO0FBRUY7QUFDRTtBQUFBO0FBRUY7QUFDRTtBQUNBO0FBQUE7QUFBQTs7O0FDOUZKO0FBQ0U7QUFDQTtBQUNBO0FBQ0E7QUFBQTtBQUVGO0FBQ0U7QUFDQTtBQUNBO0FBQUE7QUFFRjtBQUNFO0FBQ0E7QUFDQTtBQUFBO0FBRUY7QUFDRTtBQUNBO0FBQ0E7QUFBQTtBQUVGO0FBQ0U7QUFBQTtBQUVGO0FBQ0U7QUFBQTtBQUVGO0FBQ0U7QUFDQTtBQUNBO0FBQUE7QUFJRjtBQUFBO0FBRUU7QUFBQTtBQUdGO0FBQ0U7QUFDQTtBQUFBO0FBR0Y7QUFDRTtBQUFBO0FBR0Y7QUFDRTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFBQTtBQUdGO0FBQ0U7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUFBO0FBR0Y7QUFDRTtBQUNBO0FBQ0E7QUFDQTtBQUFBO0FBR0Y7QUFBQTtBQUVFO0FBQ0E7QUFDQTtBQUFBO0FBR0Y7QUFDRTtBQUFBO0FBR0Y7QUFDRTtBQUFBO0FBR0Y7QUFDRTtBQUFBO0FBRUY7QUFDRTtBQUFBO0FBR0Y7QUFBQTtBQUNPO0FBQWE7QUFBQTtBQUFBO0FBQ1g7QUFBWTtBQUFBO0FBQUE7QUFHckI7QUFBQTtBQUNPO0FBQVk7QUFBQTtBQUFBO0FBQ1Y7QUFBYTtBQUFBO0FBQUE7QUFHdEI7QUFBQTtBQUNPO0FBQUE7QUFBQTtBQUNFO0FBQUE7QUFBQTtBQUdUO0FBQUE7QUFDTztBQUFBO0FBQUE7QUFDRTtBQUFBO0FBQUE7IiwKICAibmFtZXMiOiBbXQp9Cg== */ 782 | --------------------------------------------------------------------------------