├── .tool-versions ├── assets ├── rust │ ├── .gitignore │ ├── build-rust │ ├── index.html │ ├── Cargo.toml │ ├── js │ │ └── helper.js │ └── src │ │ └── main.rs ├── css │ ├── rust.css │ ├── app.css │ └── phoenix.css ├── js │ ├── app.js │ └── user_socket.js └── vendor │ └── topbar.js ├── .devcontainer ├── devcontainer.env ├── bin │ ├── onCreateCommand │ ├── install-mix-tooling │ └── install-asdf-plugins ├── devcontainer.json ├── docker-compose.yml └── Dockerfile ├── rel └── overlays │ └── bin │ ├── server.bat │ ├── migrate.bat │ ├── server │ └── migrate ├── priv ├── repo │ ├── migrations │ │ └── .formatter.exs │ └── seeds.exs ├── static │ ├── favicon.ico │ ├── images │ │ └── phoenix.png │ └── robots.txt └── gettext │ ├── errors.pot │ └── en │ └── LC_MESSAGES │ └── errors.po ├── test ├── test_helper.exs ├── flaming_bird_flock_web │ ├── views │ │ ├── page_view_test.exs │ │ ├── layout_view_test.exs │ │ └── error_view_test.exs │ ├── controllers │ │ └── page_controller_test.exs │ └── channels │ │ └── test_channel_test.exs └── support │ ├── channel_case.ex │ ├── conn_case.ex │ └── data_case.ex ├── lib ├── flaming_bird_flock │ ├── mailer.ex │ ├── repo.ex │ ├── release.ex │ └── application.ex ├── flaming_bird_flock_web │ ├── views │ │ ├── page_view.ex │ │ ├── layout_view.ex │ │ ├── error_view.ex │ │ └── error_helpers.ex │ ├── controllers │ │ └── page_controller.ex │ ├── templates │ │ ├── layout │ │ │ ├── app.html.heex │ │ │ ├── live.html.heex │ │ │ └── root.html.heex │ │ └── page │ │ │ └── index.html.heex │ ├── gettext.ex │ ├── channels │ │ ├── test_channel.ex │ │ └── user_socket.ex │ ├── live │ │ └── rust_live.ex │ ├── router.ex │ ├── endpoint.ex │ └── telemetry.ex ├── flaming_bird_flock.ex └── flaming_bird_flock_web.ex ├── .formatter.exs ├── docker-compose.yml ├── README.md ├── .gitignore ├── fly.toml ├── .dockerignore ├── config ├── test.exs ├── config.exs ├── prod.exs ├── dev.exs └── runtime.exs ├── mix.exs ├── Dockerfile └── mix.lock /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 25.2 2 | elixir 1.14.2 -------------------------------------------------------------------------------- /assets/rust/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.env: -------------------------------------------------------------------------------- 1 | GIT_EDITOR=code --wait 2 | POSTGRES_HOST=postgres 3 | -------------------------------------------------------------------------------- /rel/overlays/bin/server.bat: -------------------------------------------------------------------------------- 1 | set PHX_SERVER=true 2 | call "%~dp0\flaming_bird_flock" start 3 | -------------------------------------------------------------------------------- /.devcontainer/bin/onCreateCommand: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ie 2 | 3 | # setup application 4 | mix setup 5 | -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /rel/overlays/bin/migrate.bat: -------------------------------------------------------------------------------- 1 | call "%~dp0\flaming_bird_flock" eval FlamingBirdFlock.Release.migrate 2 | -------------------------------------------------------------------------------- /assets/css/rust.css: -------------------------------------------------------------------------------- 1 | iframe { 2 | border: none; 3 | inline-size: 80vw; 4 | block-size: 80vh; 5 | } -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Ecto.Adapters.SQL.Sandbox.mode(FlamingBirdFlock.Repo, :manual) 3 | -------------------------------------------------------------------------------- /priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SilvanCodes/flaming_bird_flock/main/priv/static/favicon.ico -------------------------------------------------------------------------------- /rel/overlays/bin/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd -P -- "$(dirname -- "$0")" 3 | PHX_SERVER=true exec ./flaming_bird_flock start 4 | -------------------------------------------------------------------------------- /priv/static/images/phoenix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SilvanCodes/flaming_bird_flock/main/priv/static/images/phoenix.png -------------------------------------------------------------------------------- /lib/flaming_bird_flock/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlock.Mailer do 2 | use Swoosh.Mailer, otp_app: :flaming_bird_flock 3 | end 4 | -------------------------------------------------------------------------------- /lib/flaming_bird_flock_web/views/page_view.ex: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlockWeb.PageView do 2 | use FlamingBirdFlockWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /rel/overlays/bin/migrate: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd -P -- "$(dirname -- "$0")" 3 | exec ./flaming_bird_flock eval FlamingBirdFlock.Release.migrate 4 | -------------------------------------------------------------------------------- /test/flaming_bird_flock_web/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlockWeb.PageViewTest do 2 | use FlamingBirdFlockWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /lib/flaming_bird_flock/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlock.Repo do 2 | use Ecto.Repo, 3 | otp_app: :flaming_bird_flock, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | import_deps: [:ecto, :phoenix, :phoenix_live_view], 4 | plugins: [Phoenix.LiveView.HTMLFormatter], 5 | inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"] 6 | ] 7 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | # See: https://hub.docker.com/_/postgres/ 4 | postgres: 5 | image: postgres:14.4 6 | environment: 7 | - POSTGRES_USER=postgres 8 | - POSTGRES_PASSWORD=postgres 9 | -------------------------------------------------------------------------------- /lib/flaming_bird_flock_web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlockWeb.PageController do 2 | use FlamingBirdFlockWeb, :controller 3 | 4 | def index(conn, _params) do 5 | render(conn, "index.html") 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /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/flaming_bird_flock_web/templates/layout/app.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | <%= @inner_content %> 5 |
6 | -------------------------------------------------------------------------------- /test/flaming_bird_flock_web/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlockWeb.PageControllerTest do 2 | use FlamingBirdFlockWeb.ConnCase 3 | 4 | test "GET /", %{conn: conn} do 5 | conn = get(conn, "/") 6 | assert html_response(conn, 200) =~ "Welcome to Phoenix!" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/flaming_bird_flock_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlockWeb.LayoutView do 2 | use FlamingBirdFlockWeb, :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/flaming_bird_flock_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlockWeb.LayoutViewTest do 2 | use FlamingBirdFlockWeb.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 | -------------------------------------------------------------------------------- /assets/rust/build-rust: -------------------------------------------------------------------------------- 1 | #!/bin/bash -i 2 | 3 | cargo build --release --target wasm32-unknown-unknown 4 | 5 | wasm-bindgen \ 6 | --out-dir ../../priv/static/assets/ \ 7 | --target web target/wasm32-unknown-unknown/release/rust.wasm 8 | 9 | cp ./index.html ../../priv/static/assets/ 10 | cp -r ./assets/ ../../priv/static/assets/ 11 | # cp -r ./js/ ../../priv/static/assets/ -------------------------------------------------------------------------------- /lib/flaming_bird_flock.ex: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlock do 2 | @moduledoc """ 3 | FlamingBirdFlock 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 | Generated via `mix phx.new flaming_bird_flock --binary-id`. 10 | """ 11 | end 12 | -------------------------------------------------------------------------------- /assets/rust/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 15 | 16 | 20 | 21 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # FlamingBirdFlock.Repo.insert!(%FlamingBirdFlock.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /.devcontainer/bin/install-mix-tooling: -------------------------------------------------------------------------------- 1 | #!/bin/bash -i 2 | 3 | # hex (v2.0.0 -> see: https://github.com/hexpm/hex/commit/d78a23f617dda568cc11642ada459cc3b2683787) 4 | mix archive.install github hexpm/hex ref d78a23f617dda568cc11642ada459cc3b2683787 --force 5 | # rebar (matching version depending on elixir? -> see: https://github.com/elixir-lang/elixir/blob/cc9e9b29a7b473010ed17f894e6a576983a9c294/lib/mix/lib/mix/tasks/local.rebar.ex#L124) 6 | mix local.rebar --force 7 | -------------------------------------------------------------------------------- /lib/flaming_bird_flock_web/templates/layout/live.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 9 | 10 | 17 | 18 | <%= @inner_content %> 19 |
20 | -------------------------------------------------------------------------------- /.devcontainer/bin/install-asdf-plugins: -------------------------------------------------------------------------------- 1 | #!/bin/bash -i 2 | 3 | # Plugin Versions are pinned for reproducible dev environment. 4 | 5 | # install and pin erlang: https://github.com/asdf-vm/asdf-erlang 6 | asdf plugin add erlang https://github.com/asdf-vm/asdf-erlang.git 7 | asdf plugin update erlang 0d402e65a1328ed06c7920184f9806acfe251213 8 | 9 | # install and pin elixir: https://github.com/asdf-vm/asdf-elixir 10 | asdf plugin-add elixir https://github.com/asdf-vm/asdf-elixir.git 11 | asdf plugin update elixir 1693b354bb08ff24e356bbcf3be99b177c32a06e 12 | -------------------------------------------------------------------------------- /assets/rust/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rust" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | async-std = "1.12.0" 10 | bevy = "0.9.1" 11 | js-sys = "0.3.60" 12 | rand = "0.8.5" 13 | serde = { version = "1.0.152", features = ["derive"] } 14 | serde-wasm-bindgen = "0.4.5" 15 | wasm-bindgen = "0.2.83" 16 | wasm-bindgen-futures = "0.4.33" 17 | 18 | [dependencies.web-sys] 19 | version = "0.3.60" 20 | features = ['Window', 'console', 'MessageEvent'] 21 | -------------------------------------------------------------------------------- /test/flaming_bird_flock_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlockWeb.ErrorViewTest do 2 | use FlamingBirdFlockWeb.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(FlamingBirdFlockWeb.ErrorView, "404.html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(FlamingBirdFlockWeb.ErrorView, "500.html", []) == 13 | "Internal Server Error" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /assets/rust/js/helper.js: -------------------------------------------------------------------------------- 1 | // window.addEventListener("message", (event) => { 2 | // if (event.origin !== window.origin) { 3 | // console.warn("Other origin:", event.origin) 4 | // } 5 | 6 | // console.log("iframe got message:", event.data); 7 | 8 | // // … 9 | // }, false); 10 | 11 | 12 | export function postParent(message) { 13 | console.log("postParent:", message); 14 | parent.postMessage("PING", window.origin); 15 | } 16 | 17 | export function help() { 18 | window.parent.CHANNEL.push("shout", { body: "chatInput.value" }) 19 | } 20 | 21 | export default help; -------------------------------------------------------------------------------- /lib/flaming_bird_flock_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlockWeb.ErrorView do 2 | use FlamingBirdFlockWeb, :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 | -------------------------------------------------------------------------------- /lib/flaming_bird_flock/release.ex: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlock.Release do 2 | @moduledoc """ 3 | Used for executing DB release tasks when run in production without Mix 4 | installed. 5 | """ 6 | @app :flaming_bird_flock 7 | 8 | def migrate do 9 | load_app() 10 | 11 | for repo <- repos() do 12 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) 13 | end 14 | end 15 | 16 | def rollback(repo, version) do 17 | load_app() 18 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) 19 | end 20 | 21 | defp repos do 22 | Application.fetch_env!(@app, :ecto_repos) 23 | end 24 | 25 | defp load_app do 26 | Application.load(@app) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FlamingBirdFlock 2 | 3 | To start your Phoenix server: 4 | 5 | * Install dependencies with `mix deps.get` 6 | * Create and migrate your database with `mix ecto.setup` 7 | * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` 8 | 9 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 10 | 11 | Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). 12 | 13 | ## Learn more 14 | 15 | * Official website: https://www.phoenixframework.org/ 16 | * Guides: https://hexdocs.pm/phoenix/overview.html 17 | * Docs: https://hexdocs.pm/phoenix 18 | * Forum: https://elixirforum.com/c/phoenix-forum 19 | * Source: https://github.com/phoenixframework/phoenix 20 | -------------------------------------------------------------------------------- /lib/flaming_bird_flock_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlockWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import FlamingBirdFlockWeb.Gettext 9 | 10 | # Simple translation 11 | gettext("Here is the string to translate") 12 | 13 | # Plural translation 14 | ngettext("Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3) 17 | 18 | # Domain-based translation 19 | dgettext("errors", "Here is the error message to translate") 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :flaming_bird_flock 24 | end 25 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/universal 3 | { 4 | "name": "FlamingBirdFlock", 5 | "dockerComposeFile": [ 6 | "../docker-compose.yml", 7 | "docker-compose.yml" 8 | ], 9 | "service": "devcontainer", 10 | "workspaceFolder": "/workspace", 11 | "onCreateCommand": ".devcontainer/bin/onCreateCommand", 12 | "customizations": { 13 | "vscode": { 14 | "extensions": [ 15 | "JakeBecker.elixir-ls", 16 | "pantajoe.vscode-elixir-credo", 17 | "phoenixframework.phoenix", 18 | "rust-lang.rust-analyzer", 19 | "ms-azuretools.vscode-docker", 20 | "tamasfe.even-better-toml" 21 | ], 22 | "settings": { 23 | "rust-analyzer": { 24 | "linkedProjects": [ 25 | "/workspace/assets/rust/Cargo.toml" 26 | ] 27 | } 28 | } 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | foo-*.tar 24 | 25 | # Ignore assets that are produced by build tools. 26 | /priv/static/assets/ 27 | 28 | # Ignore digested assets cache. 29 | /priv/static/cache_manifest.json 30 | 31 | # In case you use Node.js/npm, you want to ignore these. 32 | npm-debug.log 33 | /assets/node_modules/ 34 | 35 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | # Update this to the name of the service you want to work with in your docker-compose.yml file 4 | devcontainer: 5 | build: 6 | dockerfile: .devcontainer/Dockerfile 7 | 8 | env_file: 9 | - .devcontainer/devcontainer.env 10 | 11 | volumes: 12 | # Update this to wherever you want VS Code to mount the folder of your project 13 | - .:/workspace:cached 14 | # Uncomment the next line to use Docker from inside the container. See https://aka.ms/vscode-remote/samples/docker-from-docker-compose for details. 15 | - /var/run/docker.sock:/var/run/docker.sock 16 | 17 | # Uncomment the next four lines if you will use a ptrace-based debugger like C++, Go, and Rust. 18 | cap_add: 19 | - SYS_PTRACE 20 | security_opt: 21 | - seccomp:unconfined 22 | # Overrides default command so things don't shut down after the process ends. 23 | command: sleep infinity 24 | -------------------------------------------------------------------------------- /test/flaming_bird_flock_web/channels/test_channel_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlockWeb.TestChannelTest do 2 | use FlamingBirdFlockWeb.ChannelCase 3 | 4 | setup do 5 | {:ok, _, socket} = 6 | FlamingBirdFlockWeb.UserSocket 7 | |> socket("user_id", %{some: :assign}) 8 | |> subscribe_and_join(FlamingBirdFlockWeb.TestChannel, "test:lobby") 9 | 10 | %{socket: socket} 11 | end 12 | 13 | test "ping replies with status ok", %{socket: socket} do 14 | ref = push(socket, "ping", %{"hello" => "there"}) 15 | assert_reply ref, :ok, %{"hello" => "there"} 16 | end 17 | 18 | test "shout broadcasts to test:lobby", %{socket: socket} do 19 | push(socket, "shout", %{"hello" => "all"}) 20 | assert_broadcast "shout", %{"hello" => "all"} 21 | end 22 | 23 | test "broadcasts are pushed to the client", %{socket: socket} do 24 | broadcast_from!(socket, "broadcast", %{"some" => "data"}) 25 | assert_push "broadcast", %{"some" => "data"} 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for flaming-bird-flock on 2022-12-18T11:19:54Z 2 | 3 | app = "flaming-bird-flock" 4 | kill_signal = "SIGTERM" 5 | kill_timeout = 5 6 | processes = [] 7 | 8 | [deploy] 9 | release_command = "/app/bin/migrate" 10 | 11 | [env] 12 | PHX_HOST = "flaming-bird-flock.fly.dev" 13 | PORT = "8080" 14 | 15 | [experimental] 16 | allowed_public_ports = [] 17 | auto_rollback = true 18 | 19 | [[services]] 20 | http_checks = [] 21 | internal_port = 8080 22 | processes = ["app"] 23 | protocol = "tcp" 24 | script_checks = [] 25 | [services.concurrency] 26 | hard_limit = 25 27 | soft_limit = 20 28 | type = "connections" 29 | 30 | [[services.ports]] 31 | force_https = true 32 | handlers = ["http"] 33 | port = 80 34 | 35 | [[services.ports]] 36 | handlers = ["tls", "http"] 37 | port = 443 38 | 39 | [[services.tcp_checks]] 40 | grace_period = "1s" 41 | interval = "15s" 42 | restart_limit = 0 43 | timeout = "2s" 44 | -------------------------------------------------------------------------------- /lib/flaming_bird_flock_web/channels/test_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlockWeb.TestChannel do 2 | use FlamingBirdFlockWeb, :channel 3 | 4 | @impl true 5 | def join("test:lobby", payload, socket) do 6 | if authorized?(payload) do 7 | {:ok, socket} 8 | else 9 | {:error, %{reason: "unauthorized"}} 10 | end 11 | end 12 | 13 | # Channels can be used in a request/response fashion 14 | # by sending replies to requests from the client 15 | @impl true 16 | def handle_in("ping", payload, socket) do 17 | {:reply, {:ok, payload}, socket} 18 | end 19 | 20 | # It is also common to receive messages from the client and 21 | # broadcast to everyone in the current topic (test:lobby). 22 | @impl true 23 | def handle_in("shout", payload, socket) do 24 | IO.inspect(payload) 25 | broadcast(socket, "shout", payload) 26 | {:noreply, socket} 27 | end 28 | 29 | # Add authorization logic here as required. 30 | defp authorized?(_payload) do 31 | true 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/flaming_bird_flock_web/live/rust_live.ex: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlockWeb.RustLive do 2 | use FlamingBirdFlockWeb, :live_view 3 | 4 | def render(assigns) do 5 | ~H""" 6 | <.center> 7 |

Rust Live

8 | 9 |

<%= @count %>

10 | 11 | 12 | 13 | 15 | 16 | """ 17 | end 18 | 19 | def mount(_params, _session, socket) do 20 | socket 21 | |> assign(count: 0) 22 | |> ok() 23 | end 24 | 25 | def handle_event("do_count", _value, socket) do 26 | send_stuff_on_channel() 27 | 28 | socket 29 | |> assign(count: socket.assigns.count + 1) 30 | |> noreply() 31 | end 32 | 33 | def send_stuff_on_channel() do 34 | FlamingBirdFlockWeb.Endpoint.broadcast!("test:lobby", "shout", %{field1: [1, 5, 6, 1]}) 35 | end 36 | 37 | defp ok(socket), do: {:ok, socket} 38 | defp noreply(socket), do: {:noreply, socket} 39 | end 40 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # flyctl launch added from .elixir_ls/.gitignore 2 | .elixir_ls/**/* 3 | 4 | # flyctl launch added from .gitignore 5 | # The directory Mix will write compiled artifacts to. 6 | _build 7 | 8 | # If you run "mix test --cover", coverage assets end up here. 9 | cover 10 | 11 | # The directory Mix downloads your dependencies sources to. 12 | deps 13 | 14 | # Where 3rd-party dependencies like ExDoc output generated docs. 15 | doc 16 | 17 | # Ignore .fetch files in case you like to edit your project deps locally. 18 | .fetch 19 | 20 | # If the VM crashes, it generates a dump, let's ignore it too. 21 | **/erl_crash.dump 22 | 23 | # Also ignore archive artifacts (built via "mix archive.build"). 24 | **/*.ez 25 | 26 | # Ignore package tarball (built via "mix hex.build"). 27 | **/foo-*.tar 28 | 29 | # Ignore assets that are produced by build tools. 30 | priv/static/assets 31 | 32 | # Ignore digested assets cache. 33 | priv/static/cache_manifest.json 34 | 35 | # In case you use Node.js/npm, you want to ignore these. 36 | **/npm-debug.log 37 | assets/node_modules 38 | 39 | fly.toml 40 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | # 5 | # The MIX_TEST_PARTITION environment variable can be used 6 | # to provide built-in test partitioning in CI environment. 7 | # Run `mix help test` for more information. 8 | config :flaming_bird_flock, FlamingBirdFlock.Repo, 9 | username: "postgres", 10 | password: "postgres", 11 | hostname: "postgres", 12 | database: "flaming_bird_flock_test#{System.get_env("MIX_TEST_PARTITION")}", 13 | pool: Ecto.Adapters.SQL.Sandbox, 14 | pool_size: 10 15 | 16 | # We don't run a server during test. If one is required, 17 | # you can enable the server option below. 18 | config :flaming_bird_flock, FlamingBirdFlockWeb.Endpoint, 19 | http: [ip: {127, 0, 0, 1}, port: 4002], 20 | secret_key_base: "wOnpN1N5HAIcl9TmgRdFDn07Ipo+WfMKYuQdnfAMpIspnNwERpd5X0tiGhf1/H9L", 21 | server: false 22 | 23 | # In test we don't send emails. 24 | config :flaming_bird_flock, FlamingBirdFlock.Mailer, adapter: Swoosh.Adapters.Test 25 | 26 | # Print only warnings and errors during test 27 | config :logger, level: :warn 28 | 29 | # Initialize plugs at runtime for faster test compilation 30 | config :phoenix, :plug_init_mode, :runtime 31 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlockWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use FlamingBirdFlockWeb.ChannelCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with channels 23 | import Phoenix.ChannelTest 24 | import FlamingBirdFlockWeb.ChannelCase 25 | 26 | # The default endpoint for testing 27 | @endpoint FlamingBirdFlockWeb.Endpoint 28 | end 29 | end 30 | 31 | setup tags do 32 | FlamingBirdFlock.DataCase.setup_sandbox(tags) 33 | :ok 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/flaming_bird_flock/application.ex: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlock.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 Ecto repository 12 | FlamingBirdFlock.Repo, 13 | # Start the Telemetry supervisor 14 | FlamingBirdFlockWeb.Telemetry, 15 | # Start the PubSub system 16 | {Phoenix.PubSub, name: FlamingBirdFlock.PubSub}, 17 | # Start the Endpoint (http/https) 18 | FlamingBirdFlockWeb.Endpoint 19 | # Start a worker by calling: FlamingBirdFlock.Worker.start_link(arg) 20 | # {FlamingBirdFlock.Worker, arg} 21 | ] 22 | 23 | # See https://hexdocs.pm/elixir/Supervisor.html 24 | # for other strategies and supported options 25 | opts = [strategy: :one_for_one, name: FlamingBirdFlock.Supervisor] 26 | Supervisor.start_link(children, opts) 27 | end 28 | 29 | # Tell Phoenix to update the endpoint configuration 30 | # whenever the application is updated. 31 | @impl true 32 | def config_change(changed, _new, removed) do 33 | FlamingBirdFlockWeb.Endpoint.config_change(changed, removed) 34 | :ok 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlockWeb.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 FlamingBirdFlockWeb.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 FlamingBirdFlockWeb.ConnCase 26 | 27 | alias FlamingBirdFlockWeb.Router.Helpers, as: Routes 28 | 29 | # The default endpoint for testing 30 | @endpoint FlamingBirdFlockWeb.Endpoint 31 | end 32 | end 33 | 34 | setup tags do 35 | FlamingBirdFlock.DataCase.setup_sandbox(tags) 36 | {:ok, conn: Phoenix.ConnTest.build_conn()} 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/flaming_bird_flock_web/templates/page/index.html.heex: -------------------------------------------------------------------------------- 1 |
2 |

<%= gettext("Welcome to %{name}!", name: "Phoenix") %>

3 |

Peace of mind from prototype to production

4 |
5 | 6 |
7 |
8 |

Resources

9 | 22 |
23 |
24 |

Help

25 | 42 |
43 |
44 | -------------------------------------------------------------------------------- /lib/flaming_bird_flock_web/templates/layout/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= live_title_tag(assigns[:page_title] || "FlamingBirdFlock", suffix: " · Phoenix Framework") %> 9 | 10 | 12 | 13 | 14 |
15 |
16 | 24 | 30 |
31 |
32 | <%= @inner_content %> 33 | 34 | 35 | -------------------------------------------------------------------------------- /lib/flaming_bird_flock_web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlockWeb.UserSocket do 2 | use Phoenix.Socket 3 | 4 | # A Socket handler 5 | # 6 | # It's possible to control the websocket connection and 7 | # assign values that can be accessed by your channel topics. 8 | 9 | ## Channels 10 | 11 | channel "test:*", FlamingBirdFlockWeb.TestChannel 12 | 13 | # Socket params are passed from the client and can 14 | # be used to verify and authenticate a user. After 15 | # verification, you can put default assigns into 16 | # the socket that will be set for all channels, ie 17 | # 18 | # {:ok, assign(socket, :user_id, verified_user_id)} 19 | # 20 | # To deny connection, return `:error`. 21 | # 22 | # See `Phoenix.Token` documentation for examples in 23 | # performing token verification on connect. 24 | @impl true 25 | def connect(_params, socket, _connect_info) do 26 | {:ok, socket} 27 | end 28 | 29 | # Socket id's are topics that allow you to identify all sockets for a given user: 30 | # 31 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}" 32 | # 33 | # Would allow you to broadcast a "disconnect" event and terminate 34 | # all active sockets and channels for a given user: 35 | # 36 | # Elixir.FlamingBirdFlockWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) 37 | # 38 | # Returning `nil` makes this socket anonymous. 39 | @impl true 40 | def id(_socket), do: nil 41 | end 42 | -------------------------------------------------------------------------------- /lib/flaming_bird_flock_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlockWeb.Router do 2 | use FlamingBirdFlockWeb, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_live_flash 8 | plug :put_root_layout, {FlamingBirdFlockWeb.LayoutView, :root} 9 | plug :protect_from_forgery 10 | plug :put_secure_browser_headers 11 | end 12 | 13 | pipeline :api do 14 | plug :accepts, ["json"] 15 | end 16 | 17 | scope "/", FlamingBirdFlockWeb do 18 | pipe_through :browser 19 | 20 | live "/rust", RustLive 21 | 22 | get "/", PageController, :index 23 | end 24 | 25 | # Other scopes may use custom stacks. 26 | # scope "/api", FlamingBirdFlockWeb do 27 | # pipe_through :api 28 | # end 29 | 30 | # Enables LiveDashboard only for development 31 | # 32 | # If you want to use the LiveDashboard in production, you should put 33 | # it behind authentication and allow only admins to access it. 34 | # If your application does not have an admins-only section yet, 35 | # you can use Plug.BasicAuth to set up some basic authentication 36 | # as long as you are also using SSL (which you should anyway). 37 | if Mix.env() in [:dev, :test] do 38 | import Phoenix.LiveDashboard.Router 39 | 40 | scope "/" do 41 | pipe_through :browser 42 | 43 | live_dashboard "/dashboard", metrics: FlamingBirdFlockWeb.Telemetry 44 | end 45 | end 46 | 47 | # Enables the Swoosh mailbox preview in development. 48 | # 49 | # Note that preview only shows emails that were sent by the same 50 | # node running the Phoenix server. 51 | if Mix.env() == :dev do 52 | scope "/dev" do 53 | pipe_through :browser 54 | 55 | forward "/mailbox", Plug.Swoosh.MailboxPreview 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/flaming_bird_flock_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlockWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | use Phoenix.HTML 7 | 8 | @doc """ 9 | Generates tag for inlined form input errors. 10 | """ 11 | def error_tag(form, field) do 12 | Enum.map(Keyword.get_values(form.errors, field), fn error -> 13 | content_tag(:span, translate_error(error), 14 | class: "invalid-feedback", 15 | phx_feedback_for: input_name(form, field) 16 | ) 17 | end) 18 | end 19 | 20 | @doc """ 21 | Translates an error message using gettext. 22 | """ 23 | def translate_error({msg, opts}) do 24 | # When using gettext, we typically pass the strings we want 25 | # to translate as a static argument: 26 | # 27 | # # Translate "is invalid" in the "errors" domain 28 | # dgettext("errors", "is invalid") 29 | # 30 | # # Translate the number of files with plural rules 31 | # dngettext("errors", "1 file", "%{count} files", count) 32 | # 33 | # Because the error messages we show in our forms and APIs 34 | # are defined inside Ecto, we need to translate them dynamically. 35 | # This requires us to call the Gettext module passing our gettext 36 | # backend as first argument. 37 | # 38 | # Note we use the "errors" domain, which means translations 39 | # should be written to the errors.po file. The :count option is 40 | # set by Ecto and indicates we should also apply plural rules. 41 | if count = opts[:count] do 42 | Gettext.dngettext(FlamingBirdFlockWeb.Gettext, "errors", msg, msg, count, opts) 43 | else 44 | Gettext.dgettext(FlamingBirdFlockWeb.Gettext, "errors", msg, opts) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/flaming_bird_flock_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlockWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :flaming_bird_flock 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: "_flaming_bird_flock_key", 10 | signing_salt: "yJEvBmQt" 11 | ] 12 | 13 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 14 | 15 | socket "/socket", FlamingBirdFlockWeb.UserSocket, 16 | websocket: true, 17 | longpoll: false 18 | 19 | # Serve at "/" the static files from "priv/static" directory. 20 | # 21 | # You should set gzip to true if you are running phx.digest 22 | # when deploying your static files in production. 23 | plug Plug.Static, 24 | at: "/", 25 | from: :flaming_bird_flock, 26 | gzip: false, 27 | only: ~w(assets fonts images favicon.ico robots.txt) 28 | 29 | # Code reloading can be explicitly enabled under the 30 | # :code_reloader configuration of your endpoint. 31 | if code_reloading? do 32 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 33 | plug Phoenix.LiveReloader 34 | plug Phoenix.CodeReloader 35 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :flaming_bird_flock 36 | end 37 | 38 | plug Phoenix.LiveDashboard.RequestLogger, 39 | param_key: "request_logger", 40 | cookie_key: "request_logger" 41 | 42 | plug Plug.RequestId 43 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 44 | 45 | plug Plug.Parsers, 46 | parsers: [:urlencoded, :multipart, :json], 47 | pass: ["*/*"], 48 | json_decoder: Phoenix.json_library() 49 | 50 | plug Plug.MethodOverride 51 | plug Plug.Head 52 | plug Plug.Session, @session_options 53 | plug FlamingBirdFlockWeb.Router 54 | end 55 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlock.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | we enable the SQL sandbox, so changes done to the database 11 | are reverted at the end of every test. If you are using 12 | PostgreSQL, you can even run database tests asynchronously 13 | by setting `use FlamingBirdFlock.DataCase, async: true`, although 14 | this option is not recommended for other databases. 15 | """ 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | using do 20 | quote do 21 | alias FlamingBirdFlock.Repo 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | import FlamingBirdFlock.DataCase 27 | end 28 | end 29 | 30 | setup tags do 31 | FlamingBirdFlock.DataCase.setup_sandbox(tags) 32 | :ok 33 | end 34 | 35 | @doc """ 36 | Sets up the sandbox based on the test tags. 37 | """ 38 | def setup_sandbox(tags) do 39 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(FlamingBirdFlock.Repo, shared: not tags[:async]) 40 | on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) 41 | end 42 | 43 | @doc """ 44 | A helper that transforms changeset errors into a map of messages. 45 | 46 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 47 | assert "password is too short" in errors_on(changeset).password 48 | assert %{password: ["password is too short"]} = errors_on(changeset) 49 | 50 | """ 51 | def errors_on(changeset) do 52 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 53 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 54 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 55 | end) 56 | end) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /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 | config :flaming_bird_flock, 11 | ecto_repos: [FlamingBirdFlock.Repo], 12 | generators: [binary_id: true] 13 | 14 | # Configures the endpoint 15 | config :flaming_bird_flock, FlamingBirdFlockWeb.Endpoint, 16 | url: [host: "localhost"], 17 | render_errors: [view: FlamingBirdFlockWeb.ErrorView, accepts: ~w(html json), layout: false], 18 | pubsub_server: FlamingBirdFlock.PubSub, 19 | live_view: [signing_salt: "GYAu6wJ7"] 20 | 21 | # Configures the mailer 22 | # 23 | # By default it uses the "Local" adapter which stores the emails 24 | # locally. You can see the emails in your browser, at "/dev/mailbox". 25 | # 26 | # For production it's recommended to configure a different adapter 27 | # at the `config/runtime.exs`. 28 | config :flaming_bird_flock, FlamingBirdFlock.Mailer, adapter: Swoosh.Adapters.Local 29 | 30 | # Swoosh API client is needed for adapters other than SMTP. 31 | config :swoosh, :api_client, false 32 | 33 | # Configure esbuild (the version is required) 34 | config :esbuild, 35 | version: "0.14.29", 36 | default: [ 37 | args: 38 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), 39 | cd: Path.expand("../assets", __DIR__), 40 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 41 | ] 42 | 43 | # Configures Elixir's Logger 44 | config :logger, :console, 45 | format: "$time $metadata[$level] $message\n", 46 | metadata: [:request_id] 47 | 48 | # Use Jason for JSON parsing in Phoenix 49 | config :phoenix, :json_library, Jason 50 | 51 | # Import environment specific config. This must remain at the bottom 52 | # of this file so it overrides the configuration defined above. 53 | import_config "#{config_env()}.exs" 54 | -------------------------------------------------------------------------------- /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 :flaming_bird_flock, FlamingBirdFlockWeb.Endpoint, 13 | cache_static_manifest: "priv/static/cache_manifest.json", 14 | force_ssl: [rewrite_on: :x_forwarded_proto] 15 | 16 | # Do not print debug messages in production 17 | config :logger, level: :info 18 | 19 | # ## SSL Support 20 | # 21 | # To get SSL working, you will need to add the `https` key 22 | # to the previous section and set your `:url` port to 443: 23 | # 24 | # config :flaming_bird_flock, FlamingBirdFlockWeb.Endpoint, 25 | # ..., 26 | # url: [host: "example.com", port: 443], 27 | # https: [ 28 | # ..., 29 | # port: 443, 30 | # cipher_suite: :strong, 31 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 32 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 33 | # ] 34 | # 35 | # The `cipher_suite` is set to `:strong` to support only the 36 | # latest and more secure SSL ciphers. This means old browsers 37 | # and clients may not be supported. You can set it to 38 | # `:compatible` for wider support. 39 | # 40 | # `:keyfile` and `:certfile` expect an absolute path to the key 41 | # and cert in disk or a relative path inside priv, for example 42 | # "priv/ssl/server.key". For all supported SSL configuration 43 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 44 | # 45 | # We also recommend setting `force_ssl` in your endpoint, ensuring 46 | # no data is ever sent via http, always redirecting to https: 47 | # 48 | # config :flaming_bird_flock, FlamingBirdFlockWeb.Endpoint, 49 | # force_ssl: [hsts: true] 50 | # 51 | # Check `Plug.SSL` for all available options in `force_ssl`. 52 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # does user setup and gives 'install-packages' script, see https://github.com/gitpod-io/workspace-images 2 | FROM gitpod/workspace-base@sha256:334d23d915611243b130aff8af6127dc360d0ea5f54cc7c4b0be6ccdfd0f56a3 3 | 4 | ENV ASDF_VERSION=v0.10.0 5 | 6 | # setup asdf-vm: https://asdf-vm.com/guide/getting-started.html 7 | RUN git clone --depth 1 https://github.com/asdf-vm/asdf.git $HOME/.asdf --branch ${ASDF_VERSION} \ 8 | && echo '. $HOME/.asdf/asdf.sh' >> $HOME/.bashrc 9 | 10 | # setup asdf plugins 11 | COPY .devcontainer/bin/install-asdf-plugins /usr/bin/ 12 | RUN install-asdf-plugins 13 | 14 | # install tolls from .tool-versions 15 | COPY .tool-versions $HOME/ 16 | RUN bash -ic 'asdf install' 17 | 18 | # install hex and rebar for Elixir's version manager mix 19 | COPY .devcontainer/bin/install-mix-tooling /usr/bin/ 20 | RUN install-mix-tooling 21 | 22 | # install Phoenix project generator 23 | RUN bash -ic 'mix archive.install hex phx_new --force' 24 | 25 | # get inotify-tools for live reload from phoenix, see: https://hexdocs.pm/phoenix/installation.html#inotify-tools-for-linux-users 26 | RUN sudo install-packages inotify-tools 27 | 28 | # install rustup, see: https://www.rust-lang.org/tools/install 29 | RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 30 | 31 | # add rustup and binaries installed via cargo to PATH 32 | ENV PATH=$HOME/.cargo/bin:$PATH 33 | 34 | ENV RUST_VERSION=1.66.0 35 | 36 | RUN rustup install $RUST_VERSION 37 | 38 | # install wasm as a compilation target for rustc 39 | RUN rustup target add wasm32-unknown-unknown 40 | 41 | # install wasm-bindgen-cli, see: https://rustwasm.github.io/docs/wasm-bindgen/introduction.html 42 | RUN bash -ic 'cargo install wasm-bindgen-cli' 43 | 44 | # required tooling for some crates 45 | RUN sudo install-packages \ 46 | # required for alsa-sys crate 47 | libasound2-dev \ 48 | # required for libudev-sys crate 49 | libudev-dev 50 | 51 | # standard tooling 52 | RUN sudo install-packages \ 53 | iputils-ping \ 54 | postgresql-client \ 55 | netcat 56 | 57 | # install flyctl, see: https://fly.io/docs/flyctl/installing/ 58 | RUN curl -L https://fly.io/install.sh | sh 59 | -------------------------------------------------------------------------------- /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 | // window.addEventListener("message", (event) => { 6 | // if (event.origin !== window.origin) { 7 | // console.warn("Other origin:", event.origin) 8 | // } 9 | 10 | // console.log("Host got message:", event.data); 11 | 12 | // // … 13 | // }, false); 14 | 15 | // If you want to use Phoenix channels, run `mix help phx.gen.channel` 16 | // to get started and then uncomment the line below. 17 | import "./user_socket.js" 18 | 19 | // You can include dependencies in two ways. 20 | // 21 | // The simplest option is to put them in assets/vendor and 22 | // import them using relative paths: 23 | // 24 | // import "../vendor/some-package.js" 25 | // 26 | // Alternatively, you can `npm install some-package --prefix assets` and import 27 | // them using a path starting with the package name: 28 | // 29 | // import "some-package" 30 | // 31 | 32 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 33 | import "phoenix_html" 34 | // Establish Phoenix Socket and LiveView configuration. 35 | import { Socket } from "phoenix" 36 | import { LiveSocket } from "phoenix_live_view" 37 | import topbar from "../vendor/topbar" 38 | 39 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 40 | let liveSocket = new LiveSocket("/live", Socket, { params: { _csrf_token: csrfToken } }) 41 | 42 | // Show progress bar on live navigation and form submits 43 | topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }) 44 | window.addEventListener("phx:page-loading-start", info => topbar.show()) 45 | window.addEventListener("phx:page-loading-stop", info => topbar.hide()) 46 | 47 | // connect if there are any LiveViews on the page 48 | liveSocket.connect() 49 | 50 | // expose liveSocket on window for web console debug logs and latency simulation: 51 | // >> liveSocket.enableDebug() 52 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 53 | // >> liveSocket.disableLatencySim() 54 | window.liveSocket = liveSocket 55 | 56 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlock.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :flaming_bird_flock, 7 | version: "0.1.0", 8 | elixir: "~> 1.12", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | compilers: Mix.compilers(), 11 | start_permanent: Mix.env() == :prod, 12 | aliases: aliases(), 13 | deps: deps() 14 | ] 15 | end 16 | 17 | # Configuration for the OTP application. 18 | # 19 | # Type `mix help compile.app` for more information. 20 | def application do 21 | [ 22 | mod: {FlamingBirdFlock.Application, []}, 23 | extra_applications: [:logger, :runtime_tools] 24 | ] 25 | end 26 | 27 | # Specifies which paths to compile per environment. 28 | defp elixirc_paths(:test), do: ["lib", "test/support"] 29 | defp elixirc_paths(_), do: ["lib"] 30 | 31 | # Specifies your project dependencies. 32 | # 33 | # Type `mix help deps` for examples and options. 34 | defp deps do 35 | [ 36 | {:phoenix, "~> 1.6.15"}, 37 | {:phoenix_ecto, "~> 4.4"}, 38 | {:ecto_sql, "~> 3.6"}, 39 | {:postgrex, ">= 0.0.0"}, 40 | {:phoenix_html, "~> 3.0"}, 41 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 42 | {:phoenix_live_view, "~> 0.17.5"}, 43 | {:floki, ">= 0.30.0", only: :test}, 44 | {:phoenix_live_dashboard, "~> 0.6"}, 45 | {:esbuild, "~> 0.4", runtime: Mix.env() == :dev}, 46 | {:swoosh, "~> 1.3"}, 47 | {:telemetry_metrics, "~> 0.6"}, 48 | {:telemetry_poller, "~> 1.0"}, 49 | {:gettext, "~> 0.18"}, 50 | {:jason, "~> 1.2"}, 51 | {:plug_cowboy, "~> 2.5"}, 52 | {:phexel, "~> 0.1", git: "https://github.com/silvanCodes/phexel"} 53 | ] 54 | end 55 | 56 | # Aliases are shortcuts or tasks specific to the current project. 57 | # For example, to install project dependencies and perform other setup tasks, run: 58 | # 59 | # $ mix setup 60 | # 61 | # See the documentation for `Mix` for more info on aliases. 62 | defp aliases do 63 | [ 64 | setup: ["deps.get", "ecto.setup", "rust.setup"], 65 | "rust.setup": ["cmd --cd assets/rust ./build-rust"], 66 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 67 | "ecto.reset": ["ecto.drop", "ecto.setup"], 68 | test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], 69 | "assets.deploy": ["esbuild default --minify", "phx.digest"] 70 | ] 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here has no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | 11 | ## From Ecto.Changeset.cast/4 12 | msgid "can't be blank" 13 | msgstr "" 14 | 15 | ## From Ecto.Changeset.unique_constraint/3 16 | msgid "has already been taken" 17 | msgstr "" 18 | 19 | ## From Ecto.Changeset.put_change/3 20 | msgid "is invalid" 21 | msgstr "" 22 | 23 | ## From Ecto.Changeset.validate_acceptance/3 24 | msgid "must be accepted" 25 | msgstr "" 26 | 27 | ## From Ecto.Changeset.validate_format/3 28 | msgid "has invalid format" 29 | msgstr "" 30 | 31 | ## From Ecto.Changeset.validate_subset/3 32 | msgid "has an invalid entry" 33 | msgstr "" 34 | 35 | ## From Ecto.Changeset.validate_exclusion/3 36 | msgid "is reserved" 37 | msgstr "" 38 | 39 | ## From Ecto.Changeset.validate_confirmation/3 40 | msgid "does not match confirmation" 41 | msgstr "" 42 | 43 | ## From Ecto.Changeset.no_assoc_constraint/3 44 | msgid "is still associated with this entry" 45 | msgstr "" 46 | 47 | msgid "are still associated with this entry" 48 | msgstr "" 49 | 50 | ## From Ecto.Changeset.validate_length/3 51 | msgid "should be %{count} character(s)" 52 | msgid_plural "should be %{count} character(s)" 53 | msgstr[0] "" 54 | msgstr[1] "" 55 | 56 | msgid "should have %{count} item(s)" 57 | msgid_plural "should have %{count} item(s)" 58 | msgstr[0] "" 59 | msgstr[1] "" 60 | 61 | msgid "should be at least %{count} character(s)" 62 | msgid_plural "should be at least %{count} character(s)" 63 | msgstr[0] "" 64 | msgstr[1] "" 65 | 66 | msgid "should have at least %{count} item(s)" 67 | msgid_plural "should have at least %{count} item(s)" 68 | msgstr[0] "" 69 | msgstr[1] "" 70 | 71 | msgid "should be at most %{count} character(s)" 72 | msgid_plural "should be at most %{count} character(s)" 73 | msgstr[0] "" 74 | msgstr[1] "" 75 | 76 | msgid "should have at most %{count} item(s)" 77 | msgid_plural "should have at most %{count} item(s)" 78 | msgstr[0] "" 79 | msgstr[1] "" 80 | 81 | ## From Ecto.Changeset.validate_number/3 82 | msgid "must be less than %{number}" 83 | msgstr "" 84 | 85 | msgid "must be greater than %{number}" 86 | msgstr "" 87 | 88 | msgid "must be less than or equal to %{number}" 89 | msgstr "" 90 | 91 | msgid "must be greater than or equal to %{number}" 92 | msgstr "" 93 | 94 | msgid "must be equal to %{number}" 95 | msgstr "" 96 | -------------------------------------------------------------------------------- /lib/flaming_bird_flock_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlockWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Phoenix Metrics 25 | summary("phoenix.endpoint.stop.duration", 26 | unit: {:native, :millisecond} 27 | ), 28 | summary("phoenix.router_dispatch.stop.duration", 29 | tags: [:route], 30 | unit: {:native, :millisecond} 31 | ), 32 | 33 | # Database Metrics 34 | summary("flaming_bird_flock.repo.query.total_time", 35 | unit: {:native, :millisecond}, 36 | description: "The sum of the other measurements" 37 | ), 38 | summary("flaming_bird_flock.repo.query.decode_time", 39 | unit: {:native, :millisecond}, 40 | description: "The time spent decoding the data received from the database" 41 | ), 42 | summary("flaming_bird_flock.repo.query.query_time", 43 | unit: {:native, :millisecond}, 44 | description: "The time spent executing the query" 45 | ), 46 | summary("flaming_bird_flock.repo.query.queue_time", 47 | unit: {:native, :millisecond}, 48 | description: "The time spent waiting for a database connection" 49 | ), 50 | summary("flaming_bird_flock.repo.query.idle_time", 51 | unit: {:native, :millisecond}, 52 | description: 53 | "The time the connection spent waiting before being checked out for the query" 54 | ), 55 | 56 | # VM Metrics 57 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 58 | summary("vm.total_run_queue_lengths.total"), 59 | summary("vm.total_run_queue_lengths.cpu"), 60 | summary("vm.total_run_queue_lengths.io") 61 | ] 62 | end 63 | 64 | defp periodic_measurements do 65 | [ 66 | # A module, function and arguments to be invoked periodically. 67 | # This function must call :telemetry.execute/3 and a metric must be added above. 68 | # {FlamingBirdFlockWeb, :count_users, []} 69 | ] 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | config :flaming_bird_flock, FlamingBirdFlock.Repo, 5 | username: "postgres", 6 | password: "postgres", 7 | hostname: "postgres", 8 | database: "flaming_bird_flock_dev", 9 | stacktrace: true, 10 | show_sensitive_data_on_connection_error: true, 11 | pool_size: 10 12 | 13 | # For development, we disable any cache and enable 14 | # debugging and code reloading. 15 | # 16 | # The watchers configuration can be used to run external 17 | # watchers to your application. For example, we use it 18 | # with esbuild to bundle .js and .css sources. 19 | config :flaming_bird_flock, FlamingBirdFlockWeb.Endpoint, 20 | # Binding to loopback ipv4 address prevents access from other machines. 21 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 22 | http: [ip: {127, 0, 0, 1}, port: 4000], 23 | check_origin: false, 24 | code_reloader: true, 25 | debug_errors: true, 26 | secret_key_base: "dKPXE0f+Ob6PP6putbIK4oLx5OQEH6USKzf4oGsqLeGzSqZJqCvXXfc+CGAXIeDg", 27 | watchers: [ 28 | # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args) 29 | esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]} 30 | ] 31 | 32 | # ## SSL Support 33 | # 34 | # In order to use HTTPS in development, a self-signed 35 | # certificate can be generated by running the following 36 | # Mix task: 37 | # 38 | # mix phx.gen.cert 39 | # 40 | # Note that this task requires Erlang/OTP 20 or later. 41 | # Run `mix help phx.gen.cert` for more information. 42 | # 43 | # The `http:` config above can be replaced with: 44 | # 45 | # https: [ 46 | # port: 4001, 47 | # cipher_suite: :strong, 48 | # keyfile: "priv/cert/selfsigned_key.pem", 49 | # certfile: "priv/cert/selfsigned.pem" 50 | # ], 51 | # 52 | # If desired, both `http:` and `https:` keys can be 53 | # configured to run both http and https servers on 54 | # different ports. 55 | 56 | # Watch static and templates for browser reloading. 57 | config :flaming_bird_flock, FlamingBirdFlockWeb.Endpoint, 58 | live_reload: [ 59 | patterns: [ 60 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 61 | ~r"priv/gettext/.*(po)$", 62 | ~r"lib/flaming_bird_flock_web/(live|views)/.*(ex)$", 63 | ~r"lib/flaming_bird_flock_web/templates/.*(eex)$" 64 | ] 65 | ] 66 | 67 | # Do not include metadata nor timestamps in development logs 68 | config :logger, :console, format: "[$level] $message\n" 69 | 70 | # Set a higher stacktrace during development. Avoid configuring such 71 | # in production as building large stacktraces may be expensive. 72 | config :phoenix, :stacktrace_depth, 20 73 | 74 | # Initialize plugs at runtime for faster development compilation 75 | config :phoenix, :plug_init_mode, :runtime 76 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian instead of 2 | # Alpine to avoid DNS resolution issues in production. 3 | # 4 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu 5 | # https://hub.docker.com/_/ubuntu?tab=tags 6 | # 7 | # 8 | # This file is based on these images: 9 | # 10 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image 11 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20221004-slim - for the release image 12 | # - https://pkgs.org/ - resource for finding needed packages 13 | # - Ex: hexpm/elixir:1.14.2-erlang-25.2-debian-bullseye-20221004-slim 14 | # 15 | ARG ELIXIR_VERSION=1.14.2 16 | ARG OTP_VERSION=25.2 17 | ARG DEBIAN_VERSION=bullseye-20221004-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/flaming_bird_flock ./ 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 | -------------------------------------------------------------------------------- /assets/js/user_socket.js: -------------------------------------------------------------------------------- 1 | // NOTE: The contents of this file will only be executed if 2 | // you uncomment its entry in "assets/js/app.js". 3 | 4 | // Bring in Phoenix channels client library: 5 | import { Socket } from "phoenix" 6 | 7 | // And connect to the path in "lib/flaming_bird_flock_web/endpoint.ex". We pass the 8 | // token for authentication. Read below how it should be used. 9 | let socket = new Socket("/socket", { params: { token: window.userToken } }) 10 | 11 | // When you connect, you'll often need to authenticate the client. 12 | // For example, imagine you have an authentication plug, `MyAuth`, 13 | // which authenticates the session and assigns a `:current_user`. 14 | // If the current user exists you can assign the user's token in 15 | // the connection for use in the layout. 16 | // 17 | // In your "lib/flaming_bird_flock_web/router.ex": 18 | // 19 | // pipeline :browser do 20 | // ... 21 | // plug MyAuth 22 | // plug :put_user_token 23 | // end 24 | // 25 | // defp put_user_token(conn, _) do 26 | // if current_user = conn.assigns[:current_user] do 27 | // token = Phoenix.Token.sign(conn, "user socket", current_user.id) 28 | // assign(conn, :user_token, token) 29 | // else 30 | // conn 31 | // end 32 | // end 33 | // 34 | // Now you need to pass this token to JavaScript. You can do so 35 | // inside a script tag in "lib/flaming_bird_flock_web/templates/layout/app.html.heex": 36 | // 37 | // 38 | // 39 | // You will need to verify the user token in the "connect/3" function 40 | // in "lib/flaming_bird_flock_web/channels/user_socket.ex": 41 | // 42 | // def connect(%{"token" => token}, socket, _connect_info) do 43 | // # max_age: 1209600 is equivalent to two weeks in seconds 44 | // case Phoenix.Token.verify(socket, "user socket", token, max_age: 1_209_600) do 45 | // {:ok, user_id} -> 46 | // {:ok, assign(socket, :user, user_id)} 47 | // 48 | // {:error, reason} -> 49 | // :error 50 | // end 51 | // end 52 | // 53 | // Finally, connect to the socket: 54 | socket.connect() 55 | 56 | // Now that you are connected, you can join channels with a topic. 57 | // Let's assume you have a channel with a topic named `room` and the 58 | // subtopic is its id - in this case 42: 59 | let channel = socket.channel("test:lobby", {}) 60 | 61 | // window.addEventListener("message", (event) => { 62 | // if (event.origin !== window.origin) { 63 | // console.warn("Other origin:", event.origin) 64 | // } 65 | 66 | // console.log("Host got message:", event.data); 67 | 68 | // // … 69 | // }, false); 70 | 71 | 72 | channel.join() 73 | .receive("ok", resp => { console.log("Joined successfully", resp) }) 74 | .receive("error", resp => { console.log("Unable to join", resp) }) 75 | 76 | window.CHANNEL = channel; 77 | 78 | export default socket 79 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | /* This file is for your main application CSS */ 2 | @import "./phoenix.css"; 3 | @import "./rust.css"; 4 | @import "../../deps/phexel/assets/elc.css"; 5 | 6 | /* Alerts and form errors used by phx.new */ 7 | .alert { 8 | padding: 15px; 9 | margin-bottom: 20px; 10 | border: 1px solid transparent; 11 | border-radius: 4px; 12 | } 13 | 14 | .alert-info { 15 | color: #31708f; 16 | background-color: #d9edf7; 17 | border-color: #bce8f1; 18 | } 19 | 20 | .alert-warning { 21 | color: #8a6d3b; 22 | background-color: #fcf8e3; 23 | border-color: #faebcc; 24 | } 25 | 26 | .alert-danger { 27 | color: #a94442; 28 | background-color: #f2dede; 29 | border-color: #ebccd1; 30 | } 31 | 32 | .alert p { 33 | margin-bottom: 0; 34 | } 35 | 36 | .alert:empty { 37 | display: none; 38 | } 39 | 40 | .invalid-feedback { 41 | color: #a94442; 42 | display: block; 43 | margin: -1rem 0 2rem; 44 | } 45 | 46 | /* LiveView specific classes for your customization */ 47 | .phx-no-feedback.invalid-feedback, 48 | .phx-no-feedback .invalid-feedback { 49 | display: none; 50 | } 51 | 52 | .phx-click-loading { 53 | opacity: 0.5; 54 | transition: opacity 1s ease-out; 55 | } 56 | 57 | .phx-loading { 58 | cursor: wait; 59 | } 60 | 61 | .phx-modal { 62 | opacity: 1 !important; 63 | position: fixed; 64 | z-index: 1; 65 | left: 0; 66 | top: 0; 67 | width: 100%; 68 | height: 100%; 69 | overflow: auto; 70 | background-color: rgba(0, 0, 0, 0.4); 71 | } 72 | 73 | .phx-modal-content { 74 | background-color: #fefefe; 75 | margin: 15vh auto; 76 | padding: 20px; 77 | border: 1px solid #888; 78 | width: 80%; 79 | } 80 | 81 | .phx-modal-close { 82 | color: #aaa; 83 | float: right; 84 | font-size: 28px; 85 | font-weight: bold; 86 | } 87 | 88 | .phx-modal-close:hover, 89 | .phx-modal-close:focus { 90 | color: black; 91 | text-decoration: none; 92 | cursor: pointer; 93 | } 94 | 95 | .fade-in-scale { 96 | animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys; 97 | } 98 | 99 | .fade-out-scale { 100 | animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys; 101 | } 102 | 103 | .fade-in { 104 | animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys; 105 | } 106 | 107 | .fade-out { 108 | animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys; 109 | } 110 | 111 | @keyframes fade-in-scale-keys { 112 | 0% { 113 | scale: 0.95; 114 | opacity: 0; 115 | } 116 | 117 | 100% { 118 | scale: 1.0; 119 | opacity: 1; 120 | } 121 | } 122 | 123 | @keyframes fade-out-scale-keys { 124 | 0% { 125 | scale: 1.0; 126 | opacity: 1; 127 | } 128 | 129 | 100% { 130 | scale: 0.95; 131 | opacity: 0; 132 | } 133 | } 134 | 135 | @keyframes fade-in-keys { 136 | 0% { 137 | opacity: 0; 138 | } 139 | 140 | 100% { 141 | opacity: 1; 142 | } 143 | } 144 | 145 | @keyframes fade-out-keys { 146 | 0% { 147 | opacity: 1; 148 | } 149 | 150 | 100% { 151 | opacity: 0; 152 | } 153 | } -------------------------------------------------------------------------------- /priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove `msgid`s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge` 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | 13 | ## From Ecto.Changeset.cast/4 14 | msgid "can't be blank" 15 | msgstr "" 16 | 17 | ## From Ecto.Changeset.unique_constraint/3 18 | msgid "has already been taken" 19 | msgstr "" 20 | 21 | ## From Ecto.Changeset.put_change/3 22 | msgid "is invalid" 23 | msgstr "" 24 | 25 | ## From Ecto.Changeset.validate_acceptance/3 26 | msgid "must be accepted" 27 | msgstr "" 28 | 29 | ## From Ecto.Changeset.validate_format/3 30 | msgid "has invalid format" 31 | msgstr "" 32 | 33 | ## From Ecto.Changeset.validate_subset/3 34 | msgid "has an invalid entry" 35 | msgstr "" 36 | 37 | ## From Ecto.Changeset.validate_exclusion/3 38 | msgid "is reserved" 39 | msgstr "" 40 | 41 | ## From Ecto.Changeset.validate_confirmation/3 42 | msgid "does not match confirmation" 43 | msgstr "" 44 | 45 | ## From Ecto.Changeset.no_assoc_constraint/3 46 | msgid "is still associated with this entry" 47 | msgstr "" 48 | 49 | msgid "are still associated with this entry" 50 | msgstr "" 51 | 52 | ## From Ecto.Changeset.validate_length/3 53 | msgid "should have %{count} item(s)" 54 | msgid_plural "should have %{count} item(s)" 55 | msgstr[0] "" 56 | msgstr[1] "" 57 | 58 | msgid "should be %{count} character(s)" 59 | msgid_plural "should be %{count} character(s)" 60 | msgstr[0] "" 61 | msgstr[1] "" 62 | 63 | msgid "should be %{count} byte(s)" 64 | msgid_plural "should be %{count} byte(s)" 65 | msgstr[0] "" 66 | msgstr[1] "" 67 | 68 | msgid "should have at least %{count} item(s)" 69 | msgid_plural "should have at least %{count} item(s)" 70 | msgstr[0] "" 71 | msgstr[1] "" 72 | 73 | msgid "should be at least %{count} character(s)" 74 | msgid_plural "should be at least %{count} character(s)" 75 | msgstr[0] "" 76 | msgstr[1] "" 77 | 78 | msgid "should be at least %{count} byte(s)" 79 | msgid_plural "should be at least %{count} byte(s)" 80 | msgstr[0] "" 81 | msgstr[1] "" 82 | 83 | msgid "should have at most %{count} item(s)" 84 | msgid_plural "should have at most %{count} item(s)" 85 | msgstr[0] "" 86 | msgstr[1] "" 87 | 88 | msgid "should be at most %{count} character(s)" 89 | msgid_plural "should be at most %{count} character(s)" 90 | msgstr[0] "" 91 | msgstr[1] "" 92 | 93 | msgid "should be at most %{count} byte(s)" 94 | msgid_plural "should be at most %{count} byte(s)" 95 | msgstr[0] "" 96 | msgstr[1] "" 97 | 98 | ## From Ecto.Changeset.validate_number/3 99 | msgid "must be less than %{number}" 100 | msgstr "" 101 | 102 | msgid "must be greater than %{number}" 103 | msgstr "" 104 | 105 | msgid "must be less than or equal to %{number}" 106 | msgstr "" 107 | 108 | msgid "must be greater than or equal to %{number}" 109 | msgstr "" 110 | 111 | msgid "must be equal to %{number}" 112 | msgstr "" 113 | -------------------------------------------------------------------------------- /lib/flaming_bird_flock_web.ex: -------------------------------------------------------------------------------- 1 | defmodule FlamingBirdFlockWeb 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 FlamingBirdFlockWeb, :controller 9 | use FlamingBirdFlockWeb, :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: FlamingBirdFlockWeb 23 | 24 | import Plug.Conn 25 | import FlamingBirdFlockWeb.Gettext 26 | alias FlamingBirdFlockWeb.Router.Helpers, as: Routes 27 | end 28 | end 29 | 30 | def view do 31 | quote do 32 | use Phoenix.View, 33 | root: "lib/flaming_bird_flock_web/templates", 34 | namespace: FlamingBirdFlockWeb 35 | 36 | # Import convenience functions from controllers 37 | import Phoenix.Controller, 38 | only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] 39 | 40 | # Include shared imports and aliases for views 41 | unquote(view_helpers()) 42 | end 43 | end 44 | 45 | def live_view do 46 | quote do 47 | use Phoenix.LiveView, 48 | layout: {FlamingBirdFlockWeb.LayoutView, "live.html"} 49 | 50 | unquote(view_helpers()) 51 | end 52 | end 53 | 54 | def live_component do 55 | quote do 56 | use Phoenix.LiveComponent 57 | 58 | unquote(view_helpers()) 59 | end 60 | end 61 | 62 | def component do 63 | quote do 64 | use Phoenix.Component 65 | 66 | unquote(view_helpers()) 67 | end 68 | end 69 | 70 | def router do 71 | quote do 72 | use Phoenix.Router 73 | 74 | import Plug.Conn 75 | import Phoenix.Controller 76 | import Phoenix.LiveView.Router 77 | end 78 | end 79 | 80 | def channel do 81 | quote do 82 | use Phoenix.Channel 83 | import FlamingBirdFlockWeb.Gettext 84 | end 85 | end 86 | 87 | defp view_helpers do 88 | quote do 89 | # Use all HTML functionality (forms, tags, etc) 90 | use Phoenix.HTML 91 | 92 | # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) 93 | import Phoenix.LiveView.Helpers 94 | 95 | # Import basic rendering functionality (render, render_layout, etc) 96 | import Phoenix.View 97 | 98 | # Layout components 99 | import Phexel 100 | 101 | import FlamingBirdFlockWeb.ErrorHelpers 102 | import FlamingBirdFlockWeb.Gettext 103 | alias FlamingBirdFlockWeb.Router.Helpers, as: Routes 104 | end 105 | end 106 | 107 | @doc """ 108 | When used, dispatch to the appropriate controller/view/etc. 109 | """ 110 | defmacro __using__(which) when is_atom(which) do 111 | apply(__MODULE__, which, []) 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /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/flaming_bird_flock 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 :flaming_bird_flock, FlamingBirdFlockWeb.Endpoint, server: true 21 | end 22 | 23 | if config_env() == :prod do 24 | database_url = 25 | System.get_env("DATABASE_URL") || 26 | raise """ 27 | environment variable DATABASE_URL is missing. 28 | For example: ecto://USER:PASS@HOST/DATABASE 29 | """ 30 | 31 | maybe_ipv6 = if System.get_env("ECTO_IPV6"), do: [:inet6], else: [] 32 | 33 | config :flaming_bird_flock, FlamingBirdFlock.Repo, 34 | # ssl: true, 35 | url: database_url, 36 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), 37 | socket_options: maybe_ipv6 38 | 39 | # The secret key base is used to sign/encrypt cookies and other secrets. 40 | # A default value is used in config/dev.exs and config/test.exs but you 41 | # want to use a different value for prod and you most likely don't want 42 | # to check this value into version control, so we use an environment 43 | # variable instead. 44 | secret_key_base = 45 | System.get_env("SECRET_KEY_BASE") || 46 | raise """ 47 | environment variable SECRET_KEY_BASE is missing. 48 | You can generate one by calling: mix phx.gen.secret 49 | """ 50 | 51 | host = System.get_env("PHX_HOST") || "example.com" 52 | port = String.to_integer(System.get_env("PORT") || "4000") 53 | 54 | config :flaming_bird_flock, FlamingBirdFlockWeb.Endpoint, 55 | url: [host: host, port: 443, scheme: "https"], 56 | http: [ 57 | # Enable IPv6 and bind on all interfaces. 58 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 59 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html 60 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 61 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 62 | port: port 63 | ], 64 | secret_key_base: secret_key_base 65 | 66 | # ## Configuring the mailer 67 | # 68 | # In production you need to configure the mailer to use a different adapter. 69 | # Also, you may need to configure the Swoosh API client of your choice if you 70 | # are not using SMTP. Here is an example of the configuration: 71 | # 72 | # config :flaming_bird_flock, FlamingBirdFlock.Mailer, 73 | # adapter: Swoosh.Adapters.Mailgun, 74 | # api_key: System.get_env("MAILGUN_API_KEY"), 75 | # domain: System.get_env("MAILGUN_DOMAIN") 76 | # 77 | # For this example you need include a HTTP client required by Swoosh API client. 78 | # Swoosh supports Hackney and Finch out of the box: 79 | # 80 | # config :swoosh, :api_client, Swoosh.ApiClient.Hackney 81 | # 82 | # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. 83 | end 84 | -------------------------------------------------------------------------------- /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, "0.1.20", "62a0126cbb7cb3e259257827b9190f88316eb7aa3fdac01fd6f2dfd64e7f46e9", [:mix], [], "hexpm", "a020b7650529c986c454a4035b6b13a328e288466986307bea3aadb4c95ac98a"}, 3 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, 4 | "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, 5 | "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"}, 6 | "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, 7 | "db_connection": {:hex, :db_connection, "2.4.3", "3b9aac9f27347ec65b271847e6baeb4443d8474289bd18c1d6f4de655b70c94d", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c127c15b0fa6cfb32eed07465e05da6c815b032508d4ed7c116122871df73c12"}, 8 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, 9 | "ecto": {:hex, :ecto, "3.9.2", "017db3bc786ff64271108522c01a5d3f6ba0aea5c84912cfb0dd73bf13684108", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "21466d5177e09e55289ac7eade579a642578242c7a3a9f91ad5c6583337a9d15"}, 10 | "ecto_sql": {:hex, :ecto_sql, "3.9.1", "9bd5894eecc53d5b39d0c95180d4466aff00e10679e13a5cfa725f6f85c03c22", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5fd470a4fff2e829bbf9dcceb7f3f9f6d1e49b4241e802f614de6b8b67c51118"}, 11 | "esbuild": {:hex, :esbuild, "0.6.0", "9ba6ead054abd43cb3d7b14946a0cdd1493698ccd8e054e0e5d6286d7f0f509c", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "30f9a05d4a5bab0d3e37398f312f80864e1ee1a081ca09149d06d474318fd040"}, 12 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 13 | "floki": {:hex, :floki, "0.34.0", "002d0cc194b48794d74711731db004fafeb328fe676976f160685262d43706a8", [:mix], [], "hexpm", "9c3a9f43f40dde00332a589bd9d389b90c1f518aef500364d00636acc5ebc99c"}, 14 | "gettext": {:hex, :gettext, "0.20.0", "75ad71de05f2ef56991dbae224d35c68b098dd0e26918def5bb45591d5c8d429", [:mix], [], "hexpm", "1c03b177435e93a47441d7f681a7040bd2a816ece9e2666d1c9001035121eb3d"}, 15 | "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, 16 | "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, 17 | "phexel": {:git, "https://github.com/silvanCodes/phexel", "3ae983d43a555ad755adc5d9e215db341b47b818", []}, 18 | "phoenix": {:hex, :phoenix, "1.6.15", "0a1d96bbc10747fd83525370d691953cdb6f3ccbac61aa01b4acb012474b047d", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d70ab9fbf6b394755ea88b644d34d79d8b146e490973151f248cacd122d20672"}, 19 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, 20 | "phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"}, 21 | "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.6.5", "1495bb014be12c9a9252eca04b9af54246f6b5c1e4cd1f30210cd00ec540cf8e", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.3", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.17.7", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "ef4fa50dd78364409039c99cf6f98ab5209b4c5f8796c17f4db118324f0db852"}, 22 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"}, 23 | "phoenix_live_view": {:hex, :phoenix_live_view, "0.17.12", "74f4c0ad02d7deac2d04f50b52827a5efdc5c6e7fac5cede145f5f0e4183aedc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.0 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "af6dd5e0aac16ff43571f527a8e0616d62cb80b10eb87aac82170243e50d99c8"}, 24 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, 25 | "phoenix_template": {:hex, :phoenix_template, "1.0.0", "c57bc5044f25f007dc86ab21895688c098a9f846a8dda6bc40e2d0ddc146e38f", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "1b066f99a26fd22064c12b2600a9a6e56700f591bf7b20b418054ea38b4d4357"}, 26 | "phoenix_view": {:hex, :phoenix_view, "2.0.2", "6bd4d2fd595ef80d33b439ede6a19326b78f0f1d8d62b9a318e3d9c1af351098", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "a929e7230ea5c7ee0e149ffcf44ce7cf7f4b6d2bfe1752dd7c084cdff152d36f"}, 27 | "plug": {:hex, :plug, "1.14.0", "ba4f558468f69cbd9f6b356d25443d0b796fbdc887e03fa89001384a9cac638f", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bf020432c7d4feb7b3af16a0c2701455cbbbb95e5b6866132cb09eb0c29adc14"}, 28 | "plug_cowboy": {:hex, :plug_cowboy, "2.6.0", "d1cf12ff96a1ca4f52207c5271a6c351a4733f413803488d75b70ccf44aebec2", [: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", "073cf20b753ce6682ed72905cd62a2d4bd9bad1bf9f7feb02a1b8e525bd94fa6"}, 29 | "plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"}, 30 | "postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"}, 31 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 32 | "swoosh": {:hex, :swoosh, "1.8.3", "733357d9a65da19c162171f08d1e42a6259236cf44d02a64711b776afbbbaa78", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c699abbac7a296c205055a7501c5d5261320ea1f08bde2392699a9e899815bc7"}, 33 | "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, 34 | "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, 35 | "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, 36 | } 37 | -------------------------------------------------------------------------------- /assets/rust/src/main.rs: -------------------------------------------------------------------------------- 1 | // use bevy::prelude::*; 2 | 3 | // fn main() { 4 | // App::new() 5 | // .add_plugins(DefaultPlugins.set(WindowPlugin { 6 | // window: WindowDescriptor { 7 | // fit_canvas_to_parent: true, 8 | // ..default() 9 | // }, 10 | // ..default() 11 | // })) 12 | // .add_startup_system(setup) 13 | // .add_system(move_sprite) 14 | // .run(); 15 | // } 16 | 17 | // fn setup(mut commands: Commands, asset_server: Res) { 18 | // commands.spawn(Camera2dBundle::default()); 19 | // commands.spawn(SpriteBundle { 20 | // texture: asset_server.load("branding/icon.png"), 21 | // ..default() 22 | // }); 23 | // } 24 | 25 | //! Eat the cakes. Eat them all. An example 3D game. 26 | 27 | //! A simplified implementation of the classic game "Breakout". 28 | 29 | use js_sys::Function; 30 | use serde::{Deserialize, Serialize}; 31 | use wasm_bindgen::prelude::*; 32 | 33 | use bevy::{ 34 | prelude::*, 35 | sprite::collide_aabb::{collide, Collision}, 36 | sprite::MaterialMesh2dBundle, 37 | time::FixedTimestep, 38 | }; 39 | 40 | #[wasm_bindgen(module = "/js/helper.js")] 41 | extern "C" { 42 | fn postParent(message: &str); 43 | 44 | fn help(); 45 | } 46 | 47 | #[wasm_bindgen] 48 | extern "C" { 49 | #[wasm_bindgen(js_namespace = ["window", "parent", "CHANNEL"], js_name = "push")] 50 | fn phoenix_push(name: &str, payload: &JsValue); 51 | 52 | #[wasm_bindgen(js_namespace = ["window", "parent", "CHANNEL"], js_name = "on")] 53 | fn phoenix_on(name: &str, callback: &Function); 54 | } 55 | 56 | // Defines the amount of time that should elapse between each physics step. 57 | const TIME_STEP: f32 = 1.0 / 60.0; 58 | 59 | // These constants are defined in `Transform` units. 60 | // Using the default 2D camera they correspond 1:1 with screen pixels. 61 | const PADDLE_SIZE: Vec3 = Vec3::new(120.0, 20.0, 0.0); 62 | const GAP_BETWEEN_PADDLE_AND_FLOOR: f32 = 60.0; 63 | const PADDLE_SPEED: f32 = 500.0; 64 | // How close can the paddle get to the wall 65 | const PADDLE_PADDING: f32 = 10.0; 66 | 67 | // We set the z-value of the ball to 1 so it renders on top in the case of overlapping sprites. 68 | const BALL_STARTING_POSITION: Vec3 = Vec3::new(0.0, -50.0, 1.0); 69 | const BALL_SIZE: Vec3 = Vec3::new(30.0, 30.0, 0.0); 70 | const BALL_SPEED: f32 = 400.0; 71 | const INITIAL_BALL_DIRECTION: Vec2 = Vec2::new(0.5, -0.5); 72 | 73 | const WALL_THICKNESS: f32 = 10.0; 74 | // x coordinates 75 | const LEFT_WALL: f32 = -450.; 76 | const RIGHT_WALL: f32 = 450.; 77 | // y coordinates 78 | const BOTTOM_WALL: f32 = -300.; 79 | const TOP_WALL: f32 = 300.; 80 | 81 | const BRICK_SIZE: Vec2 = Vec2::new(100., 30.); 82 | // These values are exact 83 | const GAP_BETWEEN_PADDLE_AND_BRICKS: f32 = 270.0; 84 | const GAP_BETWEEN_BRICKS: f32 = 5.0; 85 | // These values are lower bounds, as the number of bricks is computed 86 | const GAP_BETWEEN_BRICKS_AND_CEILING: f32 = 20.0; 87 | const GAP_BETWEEN_BRICKS_AND_SIDES: f32 = 20.0; 88 | 89 | const SCOREBOARD_FONT_SIZE: f32 = 40.0; 90 | const SCOREBOARD_TEXT_PADDING: Val = Val::Px(5.0); 91 | 92 | const BACKGROUND_COLOR: Color = Color::rgb(0.9, 0.9, 0.9); 93 | const PADDLE_COLOR: Color = Color::rgb(0.3, 0.3, 0.7); 94 | const BALL_COLOR: Color = Color::rgb(1.0, 0.5, 0.5); 95 | const BRICK_COLOR: Color = Color::rgb(0.5, 0.5, 1.0); 96 | const WALL_COLOR: Color = Color::rgb(0.8, 0.8, 0.8); 97 | const TEXT_COLOR: Color = Color::rgb(0.5, 0.5, 1.0); 98 | const SCORE_COLOR: Color = Color::rgb(1.0, 0.5, 0.5); 99 | 100 | use async_std::channel::{unbounded, Receiver, Sender}; 101 | 102 | use wasm_bindgen::JsCast; 103 | use wasm_bindgen_futures::spawn_local; 104 | use web_sys::console; 105 | 106 | /// Data sent from callback through the channel. 107 | #[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq)] 108 | pub struct Example { 109 | pub field1: [f32; 4], 110 | } 111 | 112 | /// Wrapper around Receiver, just to derive [`Resource`]. 113 | #[derive(Resource)] 114 | struct ReceiverResource { 115 | rx: async_std::channel::Receiver, 116 | } 117 | 118 | fn main() { 119 | // channel to communicate from WASM frontend to bevy runtime 120 | let (sender, receiver): (Sender, Receiver) = unbounded(); 121 | 122 | let closure = Closure::wrap(Box::new(move |payload: JsValue| { 123 | // the sender is owned by a WASM closure, called from the browser 124 | let s = sender.clone(); 125 | 126 | let example: Example = serde_wasm_bindgen::from_value(payload).expect("malformde payload"); 127 | 128 | spawn_local(async move { 129 | console::log_1(&"checking closure".into()); 130 | 131 | s.send(example).await.unwrap(); 132 | }) 133 | }) as Box); 134 | 135 | phoenix_on("shout", closure.as_ref().unchecked_ref()); 136 | 137 | phoenix_push( 138 | "shout", 139 | &serde_wasm_bindgen::to_value(&Example { 140 | field1: [0.0, 0.0, 0.0, 0.0], 141 | }) 142 | .expect("is right type"), 143 | ); 144 | 145 | // let closure = Closure::wrap(Box::new(move |event: web_sys::MessageEvent| { 146 | // // the sender is owned by a WASM closure, called from the browser 147 | // let s = sender.clone(); 148 | 149 | // let example: Example = 150 | // serde_wasm_bindgen::from_value(event.data()).expect("malformde event data"); 151 | 152 | // spawn_local(async move { 153 | // console::log_1(&"checking closure".into()); 154 | 155 | // s.send(example).await.unwrap(); 156 | // }) 157 | // }) as Box); 158 | 159 | // let window = web_sys::window().unwrap(); 160 | 161 | // let parent = window.parent().unwrap().unwrap(); 162 | 163 | // parent 164 | // .post_message( 165 | // &serde_wasm_bindgen::to_value(&Example { 166 | // field1: [0.0, 0.0, 0.0, 0.0], 167 | // }) 168 | // .expect("is right type"), 169 | // &window.origin(), 170 | // ) 171 | // .expect("couldnt postMessage"); 172 | 173 | // window.set_onmessage(Some(closure.as_ref().unchecked_ref())); 174 | 175 | // postParent("FOOO"); 176 | 177 | App::new() 178 | .insert_resource(ReceiverResource { rx: receiver }) 179 | .add_plugins(DefaultPlugins) 180 | .insert_resource(Scoreboard { score: 0 }) 181 | .insert_resource(ClearColor(BACKGROUND_COLOR)) 182 | .add_startup_system(setup) 183 | .add_event::() 184 | .add_system_set( 185 | SystemSet::new() 186 | .with_run_criteria(FixedTimestep::step(TIME_STEP as f64)) 187 | .with_system(check_for_collisions) 188 | .with_system(move_paddle.before(check_for_collisions)) 189 | .with_system(apply_velocity.before(check_for_collisions)) 190 | .with_system(play_collision_sound.after(check_for_collisions)), 191 | ) 192 | .add_system(update_scoreboard) 193 | .add_system(bevy::window::close_on_esc) 194 | .add_system(listen_from_javascript) 195 | .run(); 196 | } 197 | 198 | #[derive(Component)] 199 | struct Paddle; 200 | 201 | #[derive(Component)] 202 | struct Ball; 203 | 204 | #[derive(Component, Deref, DerefMut)] 205 | struct Velocity(Vec2); 206 | 207 | #[derive(Component)] 208 | struct Collider; 209 | 210 | #[derive(Default)] 211 | struct CollisionEvent; 212 | 213 | #[derive(Component)] 214 | struct Brick; 215 | 216 | #[derive(Resource)] 217 | struct CollisionSound(Handle); 218 | 219 | // This bundle is a collection of the components that define a "wall" in our game 220 | #[derive(Bundle)] 221 | struct WallBundle { 222 | // You can nest bundles inside of other bundles like this 223 | // Allowing you to compose their functionality 224 | sprite_bundle: SpriteBundle, 225 | collider: Collider, 226 | } 227 | 228 | /// Which side of the arena is this wall located on? 229 | enum WallLocation { 230 | Left, 231 | Right, 232 | Bottom, 233 | Top, 234 | } 235 | 236 | impl WallLocation { 237 | fn position(&self) -> Vec2 { 238 | match self { 239 | WallLocation::Left => Vec2::new(LEFT_WALL, 0.), 240 | WallLocation::Right => Vec2::new(RIGHT_WALL, 0.), 241 | WallLocation::Bottom => Vec2::new(0., BOTTOM_WALL), 242 | WallLocation::Top => Vec2::new(0., TOP_WALL), 243 | } 244 | } 245 | 246 | fn size(&self) -> Vec2 { 247 | let arena_height = TOP_WALL - BOTTOM_WALL; 248 | let arena_width = RIGHT_WALL - LEFT_WALL; 249 | // Make sure we haven't messed up our constants 250 | assert!(arena_height > 0.0); 251 | assert!(arena_width > 0.0); 252 | 253 | match self { 254 | WallLocation::Left | WallLocation::Right => { 255 | Vec2::new(WALL_THICKNESS, arena_height + WALL_THICKNESS) 256 | } 257 | WallLocation::Bottom | WallLocation::Top => { 258 | Vec2::new(arena_width + WALL_THICKNESS, WALL_THICKNESS) 259 | } 260 | } 261 | } 262 | } 263 | 264 | impl WallBundle { 265 | // This "builder method" allows us to reuse logic across our wall entities, 266 | // making our code easier to read and less prone to bugs when we change the logic 267 | fn new(location: WallLocation) -> WallBundle { 268 | WallBundle { 269 | sprite_bundle: SpriteBundle { 270 | transform: Transform { 271 | // We need to convert our Vec2 into a Vec3, by giving it a z-coordinate 272 | // This is used to determine the order of our sprites 273 | translation: location.position().extend(0.0), 274 | // The z-scale of 2D objects must always be 1.0, 275 | // or their ordering will be affected in surprising ways. 276 | // See https://github.com/bevyengine/bevy/issues/4149 277 | scale: location.size().extend(1.0), 278 | ..default() 279 | }, 280 | sprite: Sprite { 281 | color: WALL_COLOR, 282 | ..default() 283 | }, 284 | ..default() 285 | }, 286 | collider: Collider, 287 | } 288 | } 289 | } 290 | 291 | // This resource tracks the game's score 292 | #[derive(Resource)] 293 | struct Scoreboard { 294 | score: usize, 295 | } 296 | 297 | /// Function that listens to the channel, receiving data from Javascript. 298 | fn listen_from_javascript( 299 | receiver: Res>, 300 | mut scoreboard: ResMut, 301 | ) { 302 | if let Ok(example) = receiver.rx.try_recv() { 303 | web_sys::console::log_1(&format!("Received example!: {example:?}").into()); 304 | scoreboard.score += 1; 305 | } 306 | } 307 | 308 | // Add the game's entities to our world 309 | fn setup( 310 | mut commands: Commands, 311 | mut meshes: ResMut>, 312 | mut materials: ResMut>, 313 | asset_server: Res, 314 | ) { 315 | // Camera 316 | commands.spawn(Camera2dBundle::default()); 317 | 318 | // Sound 319 | let ball_collision_sound = asset_server.load("sounds/breakout_collision.ogg"); 320 | commands.insert_resource(CollisionSound(ball_collision_sound)); 321 | 322 | // Paddle 323 | let paddle_y = BOTTOM_WALL + GAP_BETWEEN_PADDLE_AND_FLOOR; 324 | 325 | commands.spawn(( 326 | SpriteBundle { 327 | transform: Transform { 328 | translation: Vec3::new(0.0, paddle_y, 0.0), 329 | scale: PADDLE_SIZE, 330 | ..default() 331 | }, 332 | sprite: Sprite { 333 | color: PADDLE_COLOR, 334 | ..default() 335 | }, 336 | ..default() 337 | }, 338 | Paddle, 339 | Collider, 340 | )); 341 | 342 | // Ball 343 | commands.spawn(( 344 | MaterialMesh2dBundle { 345 | mesh: meshes.add(shape::Circle::default().into()).into(), 346 | material: materials.add(ColorMaterial::from(BALL_COLOR)), 347 | transform: Transform::from_translation(BALL_STARTING_POSITION).with_scale(BALL_SIZE), 348 | ..default() 349 | }, 350 | Ball, 351 | Velocity(INITIAL_BALL_DIRECTION.normalize() * BALL_SPEED), 352 | )); 353 | 354 | // Scoreboard 355 | commands.spawn( 356 | TextBundle::from_sections([ 357 | TextSection::new( 358 | "Score: ", 359 | TextStyle { 360 | font: asset_server.load("fonts/FiraSans-Bold.ttf"), 361 | font_size: SCOREBOARD_FONT_SIZE, 362 | color: TEXT_COLOR, 363 | }, 364 | ), 365 | TextSection::from_style(TextStyle { 366 | font: asset_server.load("fonts/FiraMono-Medium.ttf"), 367 | font_size: SCOREBOARD_FONT_SIZE, 368 | color: SCORE_COLOR, 369 | }), 370 | ]) 371 | .with_style(Style { 372 | position_type: PositionType::Absolute, 373 | position: UiRect { 374 | top: SCOREBOARD_TEXT_PADDING, 375 | left: SCOREBOARD_TEXT_PADDING, 376 | ..default() 377 | }, 378 | ..default() 379 | }), 380 | ); 381 | 382 | // Walls 383 | commands.spawn(WallBundle::new(WallLocation::Left)); 384 | commands.spawn(WallBundle::new(WallLocation::Right)); 385 | commands.spawn(WallBundle::new(WallLocation::Bottom)); 386 | commands.spawn(WallBundle::new(WallLocation::Top)); 387 | 388 | // Bricks 389 | // Negative scales result in flipped sprites / meshes, 390 | // which is definitely not what we want here 391 | assert!(BRICK_SIZE.x > 0.0); 392 | assert!(BRICK_SIZE.y > 0.0); 393 | 394 | let total_width_of_bricks = (RIGHT_WALL - LEFT_WALL) - 2. * GAP_BETWEEN_BRICKS_AND_SIDES; 395 | let bottom_edge_of_bricks = paddle_y + GAP_BETWEEN_PADDLE_AND_BRICKS; 396 | let total_height_of_bricks = TOP_WALL - bottom_edge_of_bricks - GAP_BETWEEN_BRICKS_AND_CEILING; 397 | 398 | assert!(total_width_of_bricks > 0.0); 399 | assert!(total_height_of_bricks > 0.0); 400 | 401 | // Given the space available, compute how many rows and columns of bricks we can fit 402 | let n_columns = (total_width_of_bricks / (BRICK_SIZE.x + GAP_BETWEEN_BRICKS)).floor() as usize; 403 | let n_rows = (total_height_of_bricks / (BRICK_SIZE.y + GAP_BETWEEN_BRICKS)).floor() as usize; 404 | let n_vertical_gaps = n_columns - 1; 405 | 406 | // Because we need to round the number of columns, 407 | // the space on the top and sides of the bricks only captures a lower bound, not an exact value 408 | let center_of_bricks = (LEFT_WALL + RIGHT_WALL) / 2.0; 409 | let left_edge_of_bricks = center_of_bricks 410 | // Space taken up by the bricks 411 | - (n_columns as f32 / 2.0 * BRICK_SIZE.x) 412 | // Space taken up by the gaps 413 | - n_vertical_gaps as f32 / 2.0 * GAP_BETWEEN_BRICKS; 414 | 415 | // In Bevy, the `translation` of an entity describes the center point, 416 | // not its bottom-left corner 417 | let offset_x = left_edge_of_bricks + BRICK_SIZE.x / 2.; 418 | let offset_y = bottom_edge_of_bricks + BRICK_SIZE.y / 2.; 419 | 420 | for row in 0..n_rows { 421 | for column in 0..n_columns { 422 | let brick_position = Vec2::new( 423 | offset_x + column as f32 * (BRICK_SIZE.x + GAP_BETWEEN_BRICKS), 424 | offset_y + row as f32 * (BRICK_SIZE.y + GAP_BETWEEN_BRICKS), 425 | ); 426 | 427 | // brick 428 | commands.spawn(( 429 | SpriteBundle { 430 | sprite: Sprite { 431 | color: BRICK_COLOR, 432 | ..default() 433 | }, 434 | transform: Transform { 435 | translation: brick_position.extend(0.0), 436 | scale: Vec3::new(BRICK_SIZE.x, BRICK_SIZE.y, 1.0), 437 | ..default() 438 | }, 439 | ..default() 440 | }, 441 | Brick, 442 | Collider, 443 | )); 444 | } 445 | } 446 | } 447 | 448 | fn move_paddle( 449 | keyboard_input: Res>, 450 | mut query: Query<&mut Transform, With>, 451 | ) { 452 | let mut paddle_transform = query.single_mut(); 453 | let mut direction = 0.0; 454 | 455 | if keyboard_input.pressed(KeyCode::Left) { 456 | direction -= 1.0; 457 | } 458 | 459 | if keyboard_input.pressed(KeyCode::Right) { 460 | direction += 1.0; 461 | } 462 | 463 | // Calculate the new horizontal paddle position based on player input 464 | let new_paddle_position = paddle_transform.translation.x + direction * PADDLE_SPEED * TIME_STEP; 465 | 466 | // Update the paddle position, 467 | // making sure it doesn't cause the paddle to leave the arena 468 | let left_bound = LEFT_WALL + WALL_THICKNESS / 2.0 + PADDLE_SIZE.x / 2.0 + PADDLE_PADDING; 469 | let right_bound = RIGHT_WALL - WALL_THICKNESS / 2.0 - PADDLE_SIZE.x / 2.0 - PADDLE_PADDING; 470 | 471 | paddle_transform.translation.x = new_paddle_position.clamp(left_bound, right_bound); 472 | } 473 | 474 | fn apply_velocity(mut query: Query<(&mut Transform, &Velocity)>) { 475 | for (mut transform, velocity) in &mut query { 476 | transform.translation.x += velocity.x * TIME_STEP; 477 | transform.translation.y += velocity.y * TIME_STEP; 478 | } 479 | } 480 | 481 | fn update_scoreboard(scoreboard: Res, mut query: Query<&mut Text>) { 482 | let mut text = query.single_mut(); 483 | text.sections[1].value = scoreboard.score.to_string(); 484 | } 485 | 486 | fn check_for_collisions( 487 | mut commands: Commands, 488 | mut scoreboard: ResMut, 489 | mut ball_query: Query<(&mut Velocity, &Transform), With>, 490 | collider_query: Query<(Entity, &Transform, Option<&Brick>), With>, 491 | mut collision_events: EventWriter, 492 | ) { 493 | let (mut ball_velocity, ball_transform) = ball_query.single_mut(); 494 | let ball_size = ball_transform.scale.truncate(); 495 | 496 | // check collision with walls 497 | for (collider_entity, transform, maybe_brick) in &collider_query { 498 | let collision = collide( 499 | ball_transform.translation, 500 | ball_size, 501 | transform.translation, 502 | transform.scale.truncate(), 503 | ); 504 | if let Some(collision) = collision { 505 | // Sends a collision event so that other systems can react to the collision 506 | collision_events.send_default(); 507 | 508 | // Bricks should be despawned and increment the scoreboard on collision 509 | if maybe_brick.is_some() { 510 | scoreboard.score += 1; 511 | commands.entity(collider_entity).despawn(); 512 | } 513 | 514 | // reflect the ball when it collides 515 | let mut reflect_x = false; 516 | let mut reflect_y = false; 517 | 518 | // only reflect if the ball's velocity is going in the opposite direction of the 519 | // collision 520 | match collision { 521 | Collision::Left => reflect_x = ball_velocity.x > 0.0, 522 | Collision::Right => reflect_x = ball_velocity.x < 0.0, 523 | Collision::Top => reflect_y = ball_velocity.y < 0.0, 524 | Collision::Bottom => reflect_y = ball_velocity.y > 0.0, 525 | Collision::Inside => { /* do nothing */ } 526 | } 527 | 528 | // reflect velocity on the x-axis if we hit something on the x-axis 529 | if reflect_x { 530 | ball_velocity.x = -ball_velocity.x; 531 | } 532 | 533 | // reflect velocity on the y-axis if we hit something on the y-axis 534 | if reflect_y { 535 | ball_velocity.y = -ball_velocity.y; 536 | } 537 | } 538 | } 539 | } 540 | 541 | fn play_collision_sound( 542 | mut collision_events: EventReader, 543 | audio: Res