├── test ├── test_helper.exs ├── mine_sweeper_web │ ├── views │ │ ├── page_view_test.exs │ │ ├── layout_view_test.exs │ │ └── error_view_test.exs │ └── controllers │ │ └── page_controller_test.exs └── support │ ├── data_case.ex │ ├── channel_case.ex │ └── conn_case.ex ├── lib ├── mine_sweeper_web │ ├── templates │ │ ├── page │ │ │ └── index.html.heex │ │ └── layout │ │ │ ├── app.html.heex │ │ │ ├── live.html.heex │ │ │ └── root.html.heex │ ├── views │ │ ├── page_view.ex │ │ ├── layout_view.ex │ │ ├── error_view.ex │ │ └── error_helpers.ex │ ├── controllers │ │ └── page_controller.ex │ ├── live │ │ ├── session_live │ │ │ ├── cell_component.html.heex │ │ │ ├── form_component.ex │ │ │ ├── index.ex │ │ │ ├── index.html.heex │ │ │ ├── show.html.heex │ │ │ ├── show.ex │ │ │ ├── form_component.html.heex │ │ │ └── cell_component.ex │ │ └── live_helpers.ex │ ├── gettext.ex │ ├── router.ex │ ├── endpoint.ex │ └── telemetry.ex ├── mine_sweeper.ex ├── mine_sweeper │ ├── release.ex │ ├── application.ex │ ├── game.ex │ ├── cell_server.ex │ └── game_server.ex └── mine_sweeper_web.ex ├── rel └── overlays │ └── bin │ ├── server.bat │ ├── migrate.bat │ ├── server │ └── migrate ├── priv ├── repo │ ├── migrations │ │ └── .formatter.exs │ └── seeds.exs ├── static │ └── robots.txt └── gettext │ ├── errors.pot │ └── en │ └── LC_MESSAGES │ └── errors.po ├── .formatter.exs ├── .dockerignore ├── assets ├── tailwind.config.js ├── js │ └── app.js ├── css │ └── app.css └── vendor │ └── topbar.js ├── config ├── test.exs ├── config.exs ├── prod.exs ├── dev.exs └── runtime.exs ├── .github └── workflows │ └── vuln.yml ├── README.md ├── .gitignore ├── fly.toml ├── mix.exs ├── Dockerfile └── mix.lock /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /lib/mine_sweeper_web/templates/page/index.html.heex: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rel/overlays/bin/server.bat: -------------------------------------------------------------------------------- 1 | set PHX_SERVER=true 2 | call "%~dp0\mine_sweeper" start -------------------------------------------------------------------------------- /rel/overlays/bin/migrate.bat: -------------------------------------------------------------------------------- 1 | call "%~dp0\mine_sweeper" eval MineSweeper.Release.migrate -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /rel/overlays/bin/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd -P -- "$(dirname -- "$0")" 3 | PHX_SERVER=true exec ./mine_sweeper start 4 | -------------------------------------------------------------------------------- /lib/mine_sweeper_web/views/page_view.ex: -------------------------------------------------------------------------------- 1 | defmodule MineSweeperWeb.PageView do 2 | use MineSweeperWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /rel/overlays/bin/migrate: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd -P -- "$(dirname -- "$0")" 3 | exec ./mine_sweeper eval MineSweeper.Release.migrate -------------------------------------------------------------------------------- /test/mine_sweeper_web/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MineSweeperWeb.PageViewTest do 2 | use MineSweeperWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :phoenix], 3 | inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | subdirectories: ["priv/*/migrations"] 5 | ] 6 | -------------------------------------------------------------------------------- /lib/mine_sweeper_web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule MineSweeperWeb.PageController do 2 | use MineSweeperWeb, :controller 3 | 4 | def index(conn, _params) do 5 | redirect(conn, to: "/sessions") 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 | -------------------------------------------------------------------------------- /test/mine_sweeper_web/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MineSweeperWeb.PageControllerTest do 2 | use MineSweeperWeb.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/mine_sweeper_web/templates/layout/app.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | <%= @inner_content %> 5 |
6 | -------------------------------------------------------------------------------- /lib/mine_sweeper_web/live/session_live/cell_component.html.heex: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /lib/mine_sweeper.ex: -------------------------------------------------------------------------------- 1 | defmodule MineSweeper do 2 | @moduledoc """ 3 | MineSweeper keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .dockerignore 2 | # there are valid reasons to keep the .git, namely so that you can get the 3 | # current commit hash 4 | #.git 5 | .log 6 | tmp 7 | 8 | # Mix artifacts 9 | _build 10 | deps 11 | *.ez 12 | releases 13 | 14 | # Generate on crash by the VM 15 | erl_crash.dump 16 | 17 | # Static artifacts 18 | node_modules 19 | -------------------------------------------------------------------------------- /lib/mine_sweeper_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule MineSweeperWeb.LayoutView do 2 | use MineSweeperWeb, :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/mine_sweeper_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MineSweeperWeb.LayoutViewTest do 2 | use MineSweeperWeb.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/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // See the Tailwind configuration guide for advanced usage 2 | // https://tailwindcss.com/docs/configuration 3 | module.exports = { 4 | content: [ 5 | './js/**/*.js', 6 | '../lib/*_web.ex', 7 | '../lib/*_web/**/*.*ex' 8 | ], 9 | theme: { 10 | extend: {}, 11 | }, 12 | plugins: [ 13 | require('@tailwindcss/forms') 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /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 | # MineSweeper.Repo.insert!(%MineSweeper.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /lib/mine_sweeper_web/templates/layout/live.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 5 | 6 | 9 | 10 | <%= @inner_content %> 11 |
12 | -------------------------------------------------------------------------------- /test/mine_sweeper_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MineSweeperWeb.ErrorViewTest do 2 | use MineSweeperWeb.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(MineSweeperWeb.ErrorView, "404.html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(MineSweeperWeb.ErrorView, "500.html", []) == "Internal Server Error" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/mine_sweeper_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule MineSweeperWeb.ErrorView do 2 | use MineSweeperWeb, :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 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :mine_sweeper, MineSweeperWeb.Endpoint, 6 | http: [ip: {127, 0, 0, 1}, port: 4002], 7 | secret_key_base: "ZErmj0tg1ie/FSaMR/p8Yx7hOq1c2nCfvir/QO8l1KzkTpD/554FRBhLpkvjbswM", 8 | server: false 9 | 10 | # In test we don't send emails. 11 | config :mine_sweeper, MineSweeper.Mailer, adapter: Swoosh.Adapters.Test 12 | 13 | # Print only warnings and errors during test 14 | config :logger, level: :warn 15 | 16 | # Initialize plugs at runtime for faster test compilation 17 | config :phoenix, :plug_init_mode, :runtime 18 | -------------------------------------------------------------------------------- /.github/workflows/vuln.yml: -------------------------------------------------------------------------------- 1 | name: Scan for vuln 2 | on: [workflow_dispatch] 3 | jobs: 4 | build: 5 | name: Scan 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Checkout code 9 | uses: actions/checkout@v2 10 | - name: Build an image from Dockerfile 11 | run: | 12 | docker build -t docker.io/my-organization/my-app:${{ github.sha }} . 13 | - name: Run Trivy vulnerability scanner 14 | uses: aquasecurity/trivy-action@master 15 | with: 16 | image-ref: 'docker.io/my-organization/my-app:${{ github.sha }}' 17 | format: 'table' 18 | exit-code: '1' 19 | ignore-unfixed: true 20 | vuln-type: 'os,library' 21 | severity: 'CRITICAL,HIGH,MEDIUM,LOW' -------------------------------------------------------------------------------- /lib/mine_sweeper/release.ex: -------------------------------------------------------------------------------- 1 | defmodule MineSweeper.Release do 2 | @moduledoc """ 3 | Used for executing DB release tasks when run in production without Mix 4 | installed. 5 | """ 6 | # @app :mine_sweeper 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 | -------------------------------------------------------------------------------- /lib/mine_sweeper_web/live/session_live/form_component.ex: -------------------------------------------------------------------------------- 1 | defmodule MineSweeperWeb.SessionLive.FormComponent do 2 | use MineSweeperWeb, :live_component 3 | 4 | alias MineSweeper.Game 5 | 6 | def handle_event("start", %{"session" => session_params}, socket) do 7 | save_session(socket, socket.assigns.action, session_params) 8 | end 9 | 10 | defp save_session(socket, :new, session_params) do 11 | case Game.create_session(session_params) do 12 | {:ok, slug} -> 13 | {:noreply, 14 | socket 15 | |> put_flash(:info, "Session created successfully") 16 | |> push_redirect(to: Routes.session_show_path(socket, :show, slug))} 17 | 18 | {:error, _} -> 19 | {:noreply, put_flash(socket, :warn, "Failed to create session")} 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/mine_sweeper/application.ex: -------------------------------------------------------------------------------- 1 | defmodule MineSweeper.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | @impl true 7 | def start(_type, _args) do 8 | children = [ 9 | MineSweeperWeb.Telemetry, 10 | {Phoenix.PubSub, name: MineSweeper.PubSub}, 11 | {Registry, name: RealmRegistry, keys: :duplicate}, 12 | {Registry, name: GameRegistry, keys: :unique}, 13 | {DynamicSupervisor, strategy: :one_for_one, name: GameSupervisor}, 14 | MineSweeperWeb.Endpoint 15 | ] 16 | 17 | opts = [strategy: :one_for_one, name: MineSweeper.Supervisor] 18 | Supervisor.start_link(children, opts) 19 | end 20 | 21 | @impl true 22 | def config_change(changed, _new, removed) do 23 | MineSweeperWeb.Endpoint.config_change(changed, removed) 24 | :ok 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/mine_sweeper_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule MineSweeperWeb.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 MineSweeperWeb.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: :mine_sweeper 24 | end 25 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule MineSweeper.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 MineSweeper.DataCase, async: true`, although 14 | this option is not recommended for other databases. 15 | """ 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | using do 20 | quote do 21 | import MineSweeper.DataCase 22 | end 23 | end 24 | 25 | setup tags do 26 | :ok 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/mine_sweeper_web/live/session_live/index.ex: -------------------------------------------------------------------------------- 1 | defmodule MineSweeperWeb.SessionLive.Index do 2 | use MineSweeperWeb, :live_view 3 | 4 | alias MineSweeper.Game 5 | 6 | @impl true 7 | def mount(_params, _session, socket) do 8 | {:ok, assign(socket, :sessions, list_sessions())} 9 | end 10 | 11 | @impl true 12 | def handle_params(params, _url, socket) do 13 | {:noreply, apply_action(socket, socket.assigns.live_action, params)} 14 | end 15 | 16 | defp apply_action(socket, :new, _params) do 17 | socket 18 | |> assign(:page_title, "New Session") 19 | |> assign(:session, %{id: nil}) 20 | end 21 | 22 | defp apply_action(socket, :index, _params) do 23 | socket 24 | |> assign(:page_title, "Listing Sessions") 25 | |> assign(:session, nil) 26 | end 27 | 28 | defp list_sessions do 29 | Game.list_sessions() 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MineSweeper 2 | 3 | - Game logic: https://github.com/princemaple/mine_sweeper/tree/main/lib/mine_sweeper 4 | - Game interaction and rendering: https://github.com/princemaple/mine_sweeper/tree/main/lib/mine_sweeper_web/live/session_live 5 | - Forum post: https://elixirforum.com/t/minesweeper-built-with-liveview-and-other-recent-tools/45266 6 | 7 | ## Run locally 8 | 9 | To start your Phoenix server: 10 | 11 | * Install dependencies with `mix deps.get` 12 | * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` 13 | 14 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 15 | 16 | ## Learn more 17 | 18 | * Official website: https://www.phoenixframework.org/ 19 | * Guides: https://hexdocs.pm/phoenix/overview.html 20 | * Docs: https://hexdocs.pm/phoenix 21 | * Forum: https://elixirforum.com/c/phoenix-forum 22 | * Source: https://github.com/phoenixframework/phoenix 23 | -------------------------------------------------------------------------------- /.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 | *.gz 23 | 24 | # Ignore package tarball (built via "mix hex.build"). 25 | mine_sweeper-*.tar 26 | 27 | # Ignore assets that are produced by build tools. 28 | /priv/static/assets/ 29 | 30 | # Ignore digested assets cache. 31 | /priv/static/cache_manifest.json 32 | 33 | # In case you use Node.js/npm, you want to ignore these. 34 | npm-debug.log 35 | /assets/node_modules/ 36 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for minesweeper on 2022-01-12T13:20:59+11:00 2 | 3 | app = "minesweeper" 4 | 5 | kill_signal = "SIGTERM" 6 | kill_timeout = 5 7 | processes = [] 8 | 9 | [deploy] 10 | # release_command = "/app/bin/migrate" 11 | 12 | [env] 13 | PHX_HOST = "minesweeper.fly.dev" 14 | PORT = "8080" 15 | 16 | [experimental] 17 | allowed_public_ports = [] 18 | auto_rollback = true 19 | 20 | [[services]] 21 | http_checks = [] 22 | internal_port = 8080 23 | processes = ["app"] 24 | protocol = "tcp" 25 | script_checks = [] 26 | 27 | [services.concurrency] 28 | hard_limit = 25 29 | soft_limit = 20 30 | type = "connections" 31 | 32 | [[services.ports]] 33 | handlers = ["http"] 34 | port = 80 35 | 36 | [[services.ports]] 37 | handlers = ["tls", "http"] 38 | port = 443 39 | 40 | [[services.tcp_checks]] 41 | grace_period = "1s" 42 | interval = "15s" 43 | restart_limit = 0 44 | timeout = "2s" 45 | -------------------------------------------------------------------------------- /lib/mine_sweeper_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule MineSweeperWeb.Router do 2 | use MineSweeperWeb, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_live_flash 8 | plug :put_root_layout, {MineSweeperWeb.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 "/", MineSweeperWeb do 18 | pipe_through :browser 19 | 20 | get "/", PageController, :index 21 | 22 | live "/sessions", SessionLive.Index, :index 23 | live "/sessions/new", SessionLive.Index, :new 24 | live "/sessions/:id", SessionLive.Show, :show 25 | end 26 | 27 | if Mix.env() in [:dev, :test] do 28 | import Phoenix.LiveDashboard.Router 29 | 30 | scope "/" do 31 | pipe_through :browser 32 | 33 | live_dashboard "/dashboard", metrics: MineSweeperWeb.Telemetry 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/mine_sweeper_web/live/session_live/index.html.heex: -------------------------------------------------------------------------------- 1 |

Listing 💣 Sessions

2 | 3 | <%= if @live_action in [:new, :edit] do %> 4 | <.modal return_to={Routes.session_index_path(@socket, :index)}> 5 | <.live_component 6 | module={MineSweeperWeb.SessionLive.FormComponent} 7 | id={@session.id || :new} 8 | title={@page_title} 9 | action={@live_action} 10 | session={@session} 11 | /> 12 | 13 | <% end %> 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | <%= for {session, slug} <- @sessions do %> 23 | 24 | 25 | 26 | 29 | 30 | <% end %> 31 | 32 |
Game ID
<%= slug %> 27 | <%= live_redirect "Enter", to: "/sessions/#{slug}" %> 28 |
33 | 34 |
<%= live_patch "New Session", to: Routes.session_index_path(@socket, :new) %>
35 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule MineSweeperWeb.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 MineSweeperWeb.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 MineSweeperWeb.ChannelCase 25 | 26 | # The default endpoint for testing 27 | @endpoint MineSweeperWeb.Endpoint 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/mine_sweeper_web/live/session_live/show.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Game <%= @slug %>

4 | <%= live_redirect "Back to list", to: Routes.session_index_path(@socket, :index) %> 5 |
6 | 7 |
8 | <%= @time %> (Terminate at <%= @time_limit %>) 9 | <%= @count %>🚩/<%= @total %>💣 10 |
11 | 12 | 13 | <%= for row <- 1..@height do %> 14 | 15 | <%= for col <- 1..@width do %> 16 | 25 | <% end %> 26 | 27 | <% end %> 28 |
17 | <.live_component 18 | id={"#{@slug}-#{row}-#{col}"} 19 | module={MineSweeperWeb.SessionLive.CellComponent} 20 | slug={@slug} 21 | coords={{row, col}} 22 | version={@buster[{row, col}]} 23 | /> 24 |
29 |
30 | 31 | <%= if @ending == :win do %> 32 | 🎉🎉🎉🥳🎊🥳🎊🥳🎉🎉🎉 33 | <% end %> 34 | 35 | <%= if @ending == :lose do %> 36 | 😢😢😭😭😿😿😿😭😭😢😢 37 | <% end %> 38 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule MineSweeperWeb.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 MineSweeperWeb.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 MineSweeperWeb.ConnCase 26 | 27 | alias MineSweeperWeb.Router.Helpers, as: Routes 28 | 29 | # The default endpoint for testing 30 | @endpoint MineSweeperWeb.Endpoint 31 | end 32 | end 33 | 34 | setup tags do 35 | {:ok, conn: Phoenix.ConnTest.build_conn()} 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/mine_sweeper/game.ex: -------------------------------------------------------------------------------- 1 | defmodule MineSweeper.Game do 2 | @moduledoc """ 3 | The Game context. 4 | """ 5 | 6 | def list_sessions do 7 | Registry.lookup(RealmRegistry, :public) 8 | end 9 | 10 | def get_session!(slug) do 11 | Registry.whereis_name({GameRegistry, {:game, slug}}) 12 | end 13 | 14 | @difficulty %{ 15 | "easy" => {10, 8, 10, 3 * 60}, 16 | "medium" => {18, 14, 40, 10 * 60}, 17 | "hard" => {24, 20, 99, 30 * 60}, 18 | "extreme" => {30, 22, 145, 45 * 60} 19 | } 20 | 21 | def create_session(%{ 22 | "slug" => slug, 23 | "difficulty" => difficulty, 24 | "visibility" => visibility 25 | }) do 26 | {width, height, mine_count, time_limit} = Map.fetch!(@difficulty, difficulty) 27 | 28 | slug = 29 | if slug in [nil, ""] do 30 | Base.encode16(:crypto.strong_rand_bytes(4)) 31 | else 32 | slug 33 | end 34 | 35 | visibility = String.to_existing_atom(visibility) 36 | 37 | game = 38 | DynamicSupervisor.start_child( 39 | GameSupervisor, 40 | {MineSweeper.GameServer, 41 | slug: slug, 42 | width: width, 43 | height: height, 44 | mine_count: mine_count, 45 | visibility: visibility, 46 | time_limit: time_limit} 47 | ) 48 | 49 | with {:ok, _} <- game do 50 | {:ok, slug} 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | # Configures the endpoint 11 | config :mine_sweeper, MineSweeperWeb.Endpoint, 12 | url: [host: "localhost"], 13 | render_errors: [view: MineSweeperWeb.ErrorView, accepts: ~w(html json), layout: false], 14 | pubsub_server: MineSweeper.PubSub, 15 | live_view: [signing_salt: "OxR5VgnU"] 16 | 17 | config :tailwind, 18 | version: "3.0.12", 19 | default: [ 20 | args: ~w( 21 | --config=tailwind.config.js 22 | --input=css/app.css 23 | --output=../priv/static/assets/app.css 24 | ), 25 | cd: Path.expand("../assets", __DIR__) 26 | ] 27 | 28 | config :esbuild, 29 | version: "0.14.10", 30 | default: [ 31 | args: 32 | ~w(js/app.js --bundle --target=es2020 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), 33 | cd: Path.expand("../assets", __DIR__), 34 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 35 | ] 36 | 37 | # Configures Elixir's Logger 38 | config :logger, :console, 39 | format: "$time $metadata[$level] $message\n", 40 | metadata: [:request_id] 41 | 42 | # Use Jason for JSON parsing in Phoenix 43 | config :phoenix, :json_library, Jason 44 | 45 | # Import environment specific config. This must remain at the bottom 46 | # of this file so it overrides the configuration defined above. 47 | import_config "#{config_env()}.exs" 48 | -------------------------------------------------------------------------------- /lib/mine_sweeper_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule MineSweeperWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :mine_sweeper 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: "_mine_sweeper_key", 10 | signing_salt: "g55evaOI" 11 | ] 12 | 13 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 14 | 15 | # Serve at "/" the static files from "priv/static" directory. 16 | # 17 | # You should set gzip to true if you are running phx.digest 18 | # when deploying your static files in production. 19 | plug Plug.Static, 20 | at: "/", 21 | from: :mine_sweeper, 22 | gzip: false, 23 | only: ~w(assets fonts images favicon.ico robots.txt) 24 | 25 | # Code reloading can be explicitly enabled under the 26 | # :code_reloader configuration of your endpoint. 27 | if code_reloading? do 28 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 29 | plug Phoenix.LiveReloader 30 | plug Phoenix.CodeReloader 31 | end 32 | 33 | plug Phoenix.LiveDashboard.RequestLogger, 34 | param_key: "request_logger", 35 | cookie_key: "request_logger" 36 | 37 | plug Plug.RequestId 38 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 39 | 40 | plug Plug.Parsers, 41 | parsers: [:urlencoded, :multipart, :json], 42 | pass: ["*/*"], 43 | json_decoder: Phoenix.json_library() 44 | 45 | plug Plug.MethodOverride 46 | plug Plug.Head 47 | plug Plug.Session, @session_options 48 | plug MineSweeperWeb.Router 49 | end 50 | -------------------------------------------------------------------------------- /lib/mine_sweeper_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule MineSweeperWeb.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(MineSweeperWeb.Gettext, "errors", msg, msg, count, opts) 43 | else 44 | Gettext.dgettext(MineSweeperWeb.Gettext, "errors", msg, opts) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/mine_sweeper_web/live/session_live/show.ex: -------------------------------------------------------------------------------- 1 | defmodule MineSweeperWeb.SessionLive.Show do 2 | use MineSweeperWeb, :live_view 3 | 4 | alias MineSweeper.{Game, GameServer} 5 | 6 | @impl true 7 | def mount(_params, _session, socket) do 8 | {:ok, socket |> assign(:ending, nil)} 9 | end 10 | 11 | @impl true 12 | def handle_params(%{"id" => slug}, _, socket) do 13 | session = Game.get_session!(slug) 14 | 15 | Phoenix.PubSub.subscribe(MineSweeper.PubSub, slug) 16 | 17 | {{width, height}, time, {count, total}} = GameServer.info(session) 18 | time_limit = GameServer.time_limit(session) 19 | 20 | {:noreply, 21 | socket 22 | |> assign(:session, session) 23 | |> assign(:width, width) 24 | |> assign(:height, height) 25 | |> assign(:time, Time.from_seconds_after_midnight(time)) 26 | |> assign(:count, count) 27 | |> assign(:total, total) 28 | |> assign(:slug, slug) 29 | |> assign(:page_title, slug) 30 | |> assign(:buster, %{}) 31 | |> assign(:time_limit, Time.from_seconds_after_midnight(time_limit))} 32 | end 33 | 34 | @impl true 35 | def handle_info({:update, coords}, socket) do 36 | {:noreply, assign(socket, :buster, Map.update(socket.assigns.buster, coords, 1, &(&1 + 1)))} 37 | end 38 | 39 | @impl true 40 | def handle_info({:mark_count, count}, socket) do 41 | {:noreply, assign(socket, :count, count)} 42 | end 43 | 44 | @impl true 45 | def handle_info({:tick, time}, socket) do 46 | {:noreply, assign(socket, :time, Time.from_seconds_after_midnight(time))} 47 | end 48 | 49 | @impl true 50 | def handle_info(:win, socket) do 51 | {:noreply, assign(socket, :ending, :win)} 52 | end 53 | 54 | @impl true 55 | def handle_info(:lose, socket) do 56 | {:noreply, assign(socket, :ending, :lose)} 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule MineSweeper.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :mine_sweeper, 7 | version: "0.1.0", 8 | elixir: "~> 1.12", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | compilers: [:gettext] ++ Mix.compilers(), 11 | start_permanent: Mix.env() == :prod, 12 | aliases: aliases(), 13 | deps: deps() 14 | ] 15 | end 16 | 17 | def application do 18 | [ 19 | mod: {MineSweeper.Application, []}, 20 | extra_applications: [:logger, :runtime_tools] 21 | ] 22 | end 23 | 24 | defp elixirc_paths(:test), do: ["lib", "test/support"] 25 | defp elixirc_paths(_), do: ["lib"] 26 | 27 | defp deps do 28 | [ 29 | {:phoenix, "~> 1.6.6"}, 30 | {:phoenix_ecto, "~> 4.4"}, 31 | {:ecto_sql, "~> 3.6"}, 32 | {:phoenix_html, "~> 3.0"}, 33 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 34 | {:phoenix_live_view, "~> 0.17.5"}, 35 | {:floki, ">= 0.30.0", only: :test}, 36 | {:phoenix_live_dashboard, "~> 0.6"}, 37 | {:esbuild, "~> 0.3", runtime: Mix.env() == :dev}, 38 | {:tailwind, "~> 0.1", runtime: Mix.env() == :dev}, 39 | {:telemetry_metrics, "~> 0.6"}, 40 | {:telemetry_poller, "~> 1.0"}, 41 | {:gettext, "~> 0.18"}, 42 | {:jason, "~> 1.2"}, 43 | {:plug_cowboy, "~> 2.5"} 44 | ] 45 | end 46 | 47 | defp aliases do 48 | [ 49 | setup: ["deps.get", "ecto.setup"], 50 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 51 | "ecto.reset": ["ecto.drop", "ecto.setup"], 52 | test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], 53 | "assets.deploy": [ 54 | "tailwind default --minify", 55 | "esbuild default --minify --target=es2020", 56 | "phx.digest" 57 | ] 58 | ] 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/mine_sweeper_web/live/session_live/form_component.html.heex: -------------------------------------------------------------------------------- 1 |
2 | <%= @title %> 3 | 4 |
9 |
10 | Difficulty 11 | 15 | 16 | 20 | 21 | 25 | 26 | 30 |
31 | 32 |
33 | Visibility 34 | 38 | 39 | 43 |
44 | 45 | 50 | 51 |
52 | <%= submit "Create", phx_disable_with: "Saving..." %> 53 |
54 |
55 |
56 | -------------------------------------------------------------------------------- /lib/mine_sweeper_web/live/live_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule MineSweeperWeb.LiveHelpers do 2 | import Phoenix.LiveView 3 | import Phoenix.LiveView.Helpers 4 | 5 | alias Phoenix.LiveView.JS 6 | 7 | @doc """ 8 | Renders a live component inside a modal. 9 | 10 | The rendered modal receives a `:return_to` option to properly update 11 | the URL when the modal is closed. 12 | 13 | ## Examples 14 | 15 | <.modal return_to={Routes.session_index_path(@socket, :index)}> 16 | <.live_component 17 | module={MineSweeperWeb.SessionLive.FormComponent} 18 | id={@session.id || :new} 19 | title={@page_title} 20 | action={@live_action} 21 | return_to={Routes.session_index_path(@socket, :index)} 22 | session: @session 23 | /> 24 | 25 | """ 26 | def modal(assigns) do 27 | assigns = assign_new(assigns, :return_to, fn -> nil end) 28 | 29 | ~H""" 30 | 52 | """ 53 | end 54 | 55 | defp hide_modal(js \\ %JS{}) do 56 | js 57 | |> JS.hide(to: "#modal", transition: "fade-out") 58 | |> JS.hide(to: "#modal-content", transition: "fade-out-scale") 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /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 :mine_sweeper, MineSweeperWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" 13 | 14 | # Do not print debug messages in production 15 | config :logger, level: :info 16 | 17 | # ## SSL Support 18 | # 19 | # To get SSL working, you will need to add the `https` key 20 | # to the previous section and set your `:url` port to 443: 21 | # 22 | # config :mine_sweeper, MineSweeperWeb.Endpoint, 23 | # ..., 24 | # url: [host: "example.com", port: 443], 25 | # https: [ 26 | # ..., 27 | # port: 443, 28 | # cipher_suite: :strong, 29 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 30 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 31 | # ] 32 | # 33 | # The `cipher_suite` is set to `:strong` to support only the 34 | # latest and more secure SSL ciphers. This means old browsers 35 | # and clients may not be supported. You can set it to 36 | # `:compatible` for wider support. 37 | # 38 | # `:keyfile` and `:certfile` expect an absolute path to the key 39 | # and cert in disk or a relative path inside priv, for example 40 | # "priv/ssl/server.key". For all supported SSL configuration 41 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 42 | # 43 | # We also recommend setting `force_ssl` in your endpoint, ensuring 44 | # no data is ever sent via http, always redirecting to https: 45 | # 46 | # config :mine_sweeper, MineSweeperWeb.Endpoint, 47 | # force_ssl: [hsts: true] 48 | # 49 | # Check `Plug.SSL` for all available options in `force_ssl`. 50 | -------------------------------------------------------------------------------- /lib/mine_sweeper_web/templates/layout/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | <%= csrf_meta_tag() %> 12 | <%= live_title_tag "MineSweeper", suffix: " · Liveview" %> 13 | 14 | 15 | 16 | 17 | 18 | 19 | <%= @inner_content %> 20 | 21 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we use it 8 | # with esbuild to bundle .js and .css sources. 9 | config :mine_sweeper, MineSweeperWeb.Endpoint, 10 | # Binding to loopback ipv4 address prevents access from other machines. 11 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 12 | http: [ip: {127, 0, 0, 1}, port: 4000], 13 | check_origin: false, 14 | code_reloader: true, 15 | debug_errors: true, 16 | secret_key_base: "7no23vf09BSAtqhGXS9mXdvc8KQTVnVYyNOCFAqqgvY6+85MmggJyomzxKIaWnkg", 17 | watchers: [ 18 | tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]}, 19 | esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]} 20 | ] 21 | 22 | # ## SSL Support 23 | # 24 | # In order to use HTTPS in development, a self-signed 25 | # certificate can be generated by running the following 26 | # Mix task: 27 | # 28 | # mix phx.gen.cert 29 | # 30 | # Note that this task requires Erlang/OTP 20 or later. 31 | # Run `mix help phx.gen.cert` for more information. 32 | # 33 | # The `http:` config above can be replaced with: 34 | # 35 | # https: [ 36 | # port: 4001, 37 | # cipher_suite: :strong, 38 | # keyfile: "priv/cert/selfsigned_key.pem", 39 | # certfile: "priv/cert/selfsigned.pem" 40 | # ], 41 | # 42 | # If desired, both `http:` and `https:` keys can be 43 | # configured to run both http and https servers on 44 | # different ports. 45 | 46 | # Watch static and templates for browser reloading. 47 | config :mine_sweeper, MineSweeperWeb.Endpoint, 48 | live_reload: [ 49 | patterns: [ 50 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 51 | ~r"priv/gettext/.*(po)$", 52 | ~r"lib/mine_sweeper_web/(live|views)/.*(ex)$", 53 | ~r"lib/mine_sweeper_web/templates/.*(eex)$" 54 | ] 55 | ] 56 | 57 | # Do not include metadata nor timestamps in development logs 58 | config :logger, :console, format: "[$level] $message\n" 59 | 60 | # Set a higher stacktrace during development. Avoid configuring such 61 | # in production as building large stacktraces may be expensive. 62 | config :phoenix, :stacktrace_depth, 20 63 | 64 | # Initialize plugs at runtime for faster development compilation 65 | config :phoenix, :plug_init_mode, :runtime 66 | -------------------------------------------------------------------------------- /lib/mine_sweeper_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule MineSweeperWeb.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("mine_sweeper.repo.query.total_time", 35 | unit: {:native, :millisecond}, 36 | description: "The sum of the other measurements" 37 | ), 38 | summary("mine_sweeper.repo.query.decode_time", 39 | unit: {:native, :millisecond}, 40 | description: "The time spent decoding the data received from the database" 41 | ), 42 | summary("mine_sweeper.repo.query.query_time", 43 | unit: {:native, :millisecond}, 44 | description: "The time spent executing the query" 45 | ), 46 | summary("mine_sweeper.repo.query.queue_time", 47 | unit: {:native, :millisecond}, 48 | description: "The time spent waiting for a database connection" 49 | ), 50 | summary("mine_sweeper.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 | # {MineSweeperWeb, :count_users, []} 69 | ] 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian instead of 2 | # Alpine to avoid DNS resolution issues in production. 3 | # 4 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu 5 | # https://hub.docker.com/_/ubuntu?tab=tags 6 | # 7 | # 8 | # This file is based on these images: 9 | # 10 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image 11 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20210902-slim - for the release image 12 | # - https://pkgs.org/ - resource for finding needed packages 13 | # - Ex: hexpm/elixir:1.13.1-erlang-24.2-debian-bullseye-20210902-slim 14 | # 15 | ARG BUILDER_IMAGE="hexpm/elixir:1.13.1-erlang-24.2-debian-bullseye-20210902-slim" 16 | ARG RUNNER_IMAGE="debian:bullseye-20210902-slim" 17 | 18 | FROM ${BUILDER_IMAGE} as builder 19 | 20 | # install build dependencies 21 | RUN apt-get update -y && apt-get install -y build-essential git \ 22 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 23 | 24 | # prepare build dir 25 | WORKDIR /app 26 | 27 | # install hex + rebar 28 | RUN mix local.hex --force && \ 29 | mix local.rebar --force 30 | 31 | # set build ENV 32 | ENV MIX_ENV="prod" 33 | 34 | # install mix dependencies 35 | COPY mix.exs mix.lock ./ 36 | RUN mix deps.get 37 | RUN mkdir config 38 | 39 | # copy compile-time config files before we compile dependencies 40 | # to ensure any relevant config change will trigger the dependencies 41 | # to be re-compiled. 42 | COPY config/config.exs config/${MIX_ENV}.exs config/ 43 | RUN mix do deps.compile, tailwind.install, esbuild.install 44 | 45 | COPY priv priv 46 | COPY assets assets 47 | COPY lib lib 48 | 49 | RUN mix do assets.deploy, compile 50 | 51 | COPY config/runtime.exs config/ 52 | COPY rel rel 53 | 54 | RUN mix release 55 | 56 | # start a new build stage so that the final image will only contain 57 | # the compiled release and other runtime necessities 58 | FROM ${RUNNER_IMAGE} 59 | 60 | RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales \ 61 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 62 | 63 | # Set the locale 64 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen 65 | 66 | ENV LANG en_US.UTF-8 67 | ENV LANGUAGE en_US:en 68 | ENV LC_ALL en_US.UTF-8 69 | 70 | WORKDIR "/app" 71 | RUN chown nobody /app 72 | 73 | # Only copy the final release from the build stage 74 | COPY --from=builder --chown=nobody:root /app/_build/prod/rel/mine_sweeper ./ 75 | 76 | USER nobody 77 | 78 | CMD ["/app/bin/server"] 79 | 80 | # Appended by flyctl 81 | ENV ECTO_IPV6 true 82 | ENV ERL_AFLAGS "-proto_dist inet6_tcp" 83 | -------------------------------------------------------------------------------- /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/mine_sweeper_web/live/session_live/cell_component.ex: -------------------------------------------------------------------------------- 1 | defmodule MineSweeperWeb.SessionLive.CellComponent do 2 | use MineSweeperWeb, :live_component 3 | 4 | alias MineSweeper.CellServer 5 | 6 | @impl true 7 | def update(%{version: version}, %{assigns: %{version: version}} = socket) do 8 | {:ok, socket} 9 | end 10 | 11 | @impl true 12 | def update(%{slug: slug, coords: coords, version: version}, socket) do 13 | cell = CellServer.get(CellServer.via(slug, coords)) 14 | 15 | {:ok, 16 | socket 17 | |> assign(:slug, slug) 18 | |> assign(:coords, coords) 19 | |> assign(:row, elem(coords, 0)) 20 | |> assign(:col, elem(coords, 1)) 21 | |> assign(:version, version) 22 | |> assign(:cell, cell)} 23 | end 24 | 25 | @impl true 26 | def handle_event("reveal", _payload, %{assigns: %{slug: slug, coords: coords}} = socket) do 27 | cell = CellServer.reveal(CellServer.via(slug, coords)) 28 | {:noreply, socket |> assign(:cell, cell)} 29 | end 30 | 31 | @impl true 32 | def handle_event("mark", _payload, %{assigns: %{slug: slug, coords: coords}} = socket) do 33 | cell = CellServer.mark(CellServer.via(slug, coords)) 34 | {:noreply, socket |> assign(:cell, cell)} 35 | end 36 | 37 | @impl true 38 | def handle_event( 39 | "detect", 40 | _payload, 41 | %{assigns: %{cell: %{revealed?: true, value: value}, slug: slug, coords: coords}} = socket 42 | ) 43 | when is_integer(value) and value > 0 do 44 | CellServer.detect(CellServer.via(slug, coords)) 45 | {:noreply, push_event(socket, "detect", %{row: elem(coords, 0), col: elem(coords, 1)})} 46 | end 47 | 48 | @impl true 49 | def handle_event("detect", _payload, socket) do 50 | {:noreply, socket} 51 | end 52 | 53 | def show(%{revealed?: true, value: :mine}), do: "💣" 54 | def show(%{revealed?: true, value: 0}), do: " " 55 | def show(%{revealed?: true, value: v}), do: v 56 | def show(%{revealed?: false, marked?: true}), do: "🚩" 57 | def show(%{revealed?: false}), do: " " 58 | 59 | @color List.to_tuple(~w( 60 | text-current 61 | text-blue-600 62 | text-green-600 63 | text-red-600 64 | text-purple-600 65 | text-red-900 66 | text-teal-400 67 | text-black-600 68 | text-gray-600 69 | )) 70 | def class(%{revealed?: revealed?, marked?: marked?, opaque?: opaque?, value: value}) do 71 | [ 72 | revealed? && "revealed cursor-default", 73 | cond do 74 | marked? -> "bg-yellow-200" 75 | !revealed? -> "bg-gray-200" 76 | revealed? && value == :mine -> "bg-red-200" 77 | revealed? && value == 0 -> "bg-gray-100" 78 | revealed? -> "bg-blue-200" 79 | end, 80 | opaque? && "blur-sm", 81 | is_integer(value) && elem(@color, value) 82 | ] 83 | |> Enum.filter(& &1) 84 | |> Enum.join(" ") 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /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 | # Start the phoenix server if environment is set and running in a release 11 | if System.get_env("PHX_SERVER") && System.get_env("RELEASE_NAME") do 12 | config :mine_sweeper, MineSweeperWeb.Endpoint, server: true 13 | end 14 | 15 | if config_env() == :prod do 16 | # The secret key base is used to sign/encrypt cookies and other secrets. 17 | # A default value is used in config/dev.exs and config/test.exs but you 18 | # want to use a different value for prod and you most likely don't want 19 | # to check this value into version control, so we use an environment 20 | # variable instead. 21 | secret_key_base = 22 | System.get_env("SECRET_KEY_BASE") || 23 | raise """ 24 | environment variable SECRET_KEY_BASE is missing. 25 | You can generate one by calling: mix phx.gen.secret 26 | """ 27 | 28 | host = System.get_env("PHX_HOST") || "example.com" 29 | port = String.to_integer(System.get_env("PORT") || "4000") 30 | 31 | config :mine_sweeper, MineSweeperWeb.Endpoint, 32 | url: [host: host, port: 443], 33 | http: [ 34 | # Enable IPv6 and bind on all interfaces. 35 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 36 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html 37 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 38 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 39 | port: port 40 | ], 41 | secret_key_base: secret_key_base 42 | 43 | # ## Using releases 44 | # 45 | # If you are doing OTP releases, you need to instruct Phoenix 46 | # to start each relevant endpoint: 47 | # 48 | # config :mine_sweeper, MineSweeperWeb.Endpoint, server: true 49 | # 50 | # Then you can assemble a release by calling `mix release`. 51 | # See `mix help release` for more information. 52 | 53 | # ## Configuring the mailer 54 | # 55 | # In production you need to configure the mailer to use a different adapter. 56 | # Also, you may need to configure the Swoosh API client of your choice if you 57 | # are not using SMTP. Here is an example of the configuration: 58 | # 59 | # config :mine_sweeper, MineSweeper.Mailer, 60 | # adapter: Swoosh.Adapters.Mailgun, 61 | # api_key: System.get_env("MAILGUN_API_KEY"), 62 | # domain: System.get_env("MAILGUN_DOMAIN") 63 | # 64 | # For this example you need include a HTTP client required by Swoosh API client. 65 | # Swoosh supports Hackney and Finch out of the box: 66 | # 67 | # config :swoosh, :api_client, Swoosh.ApiClient.Hackney 68 | # 69 | # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. 70 | end 71 | -------------------------------------------------------------------------------- /lib/mine_sweeper_web.ex: -------------------------------------------------------------------------------- 1 | defmodule MineSweeperWeb 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 MineSweeperWeb, :controller 9 | use MineSweeperWeb, :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: MineSweeperWeb 23 | 24 | import Plug.Conn 25 | import MineSweeperWeb.Gettext 26 | alias MineSweeperWeb.Router.Helpers, as: Routes 27 | end 28 | end 29 | 30 | def view do 31 | quote do 32 | use Phoenix.View, 33 | root: "lib/mine_sweeper_web/templates", 34 | namespace: MineSweeperWeb 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: {MineSweeperWeb.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 MineSweeperWeb.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 | import MineSweeperWeb.LiveHelpers 95 | 96 | # Import basic rendering functionality (render, render_layout, etc) 97 | import Phoenix.View 98 | 99 | import MineSweeperWeb.ErrorHelpers 100 | import MineSweeperWeb.Gettext 101 | alias MineSweeperWeb.Router.Helpers, as: Routes 102 | end 103 | end 104 | 105 | @doc """ 106 | When used, dispatch to the appropriate controller/view/etc. 107 | """ 108 | defmacro __using__(which) when is_atom(which) do 109 | apply(__MODULE__, which, []) 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // If you want to use Phoenix channels, run `mix help phx.gen.channel` 2 | // to get started and then uncomment the line below. 3 | // import "./user_socket.js" 4 | 5 | // You can include dependencies in two ways. 6 | // 7 | // The simplest option is to put them in assets/vendor and 8 | // import them using relative paths: 9 | // 10 | // import "../vendor/some-package.js" 11 | // 12 | // Alternatively, you can `npm install some-package --prefix assets` and import 13 | // them using a path starting with the package name: 14 | // 15 | // import "some-package" 16 | // 17 | 18 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 19 | import 'phoenix_html'; 20 | // Establish Phoenix Socket and LiveView configuration. 21 | import {Socket} from 'phoenix'; 22 | import {LiveSocket} from 'phoenix_live_view'; 23 | import topbar from '../vendor/topbar'; 24 | 25 | const NoContextMenu = el => { 26 | el.addEventListener('contextmenu', e => { 27 | e.preventDefault(); 28 | e.stopImmediatePropagation(); 29 | e.cancelBubble = true; 30 | return false; 31 | }); 32 | }; 33 | 34 | const hooks = {}; 35 | 36 | hooks.CellButton = { 37 | mounted() { 38 | NoContextMenu(this.el); 39 | 40 | this.el.addEventListener('mouseup', e => { 41 | if (e.button == 0) { 42 | this.pushEventTo(e.target.attributes.getNamedItem('phx-target').value, 'reveal'); 43 | } 44 | }); 45 | this.el.addEventListener('mousedown', e => { 46 | if (e.buttons == 2) { 47 | this.pushEventTo(e.target.attributes.getNamedItem('phx-target').value, 'mark'); 48 | } 49 | if (e.buttons == 3) { 50 | this.pushEventTo(e.target.attributes.getNamedItem('phx-target').value, 'detect'); 51 | } 52 | }); 53 | 54 | this.row = parseInt(this.el.dataset['row']); 55 | this.col = parseInt(this.el.dataset['col']); 56 | 57 | const isNeighbour = e => 58 | [-1, 0, 1].map(d => d + this.row).includes(e.row) && 59 | [-1, 0, 1].map(d => d + this.col).includes(e.col) && 60 | (e.row != this.row || e.col != this.col); 61 | 62 | this.handleEvent('detect', e => { 63 | if (isNeighbour(e)) { 64 | this.el.classList.add('shake'); 65 | setTimeout(() => { 66 | this.el.classList.remove('shake'); 67 | }, 200); 68 | } 69 | }); 70 | }, 71 | }; 72 | 73 | hooks.MineField = { 74 | mounted() { 75 | NoContextMenu(this.el); 76 | }, 77 | }; 78 | 79 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content'); 80 | let liveSocket = new LiveSocket('/live', Socket, {hooks, params: {_csrf_token: csrfToken}}); 81 | 82 | // Show progress bar on live navigation and form submits 83 | topbar.config({barColors: {0: '#29d'}, shadowColor: 'rgba(0, 0, 0, .3)'}); 84 | window.addEventListener('phx:page-loading-start', () => topbar.show()); 85 | window.addEventListener('phx:page-loading-stop', () => topbar.hide()); 86 | 87 | // connect if there are any LiveViews on the page 88 | liveSocket.connect(); 89 | 90 | // expose liveSocket on window for web console debug logs and latency simulation: 91 | // >> liveSocket.enableDebug() 92 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 93 | // >> liveSocket.disableLatencySim() 94 | window.liveSocket = liveSocket; 95 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind utilities; 3 | 4 | /* Alerts and form errors used by phx.new */ 5 | .alert { 6 | padding: 15px; 7 | margin-top: 1em; 8 | border: 1px solid transparent; 9 | border-radius: 4px; 10 | } 11 | .alert-info { 12 | color: #31708f; 13 | background-color: #d9edf7; 14 | border-color: #bce8f1; 15 | } 16 | .alert-warning { 17 | color: #8a6d3b; 18 | background-color: #fcf8e3; 19 | border-color: #faebcc; 20 | } 21 | .alert-danger { 22 | color: #a94442; 23 | background-color: #f2dede; 24 | border-color: #ebccd1; 25 | } 26 | .alert p { 27 | margin-bottom: 0; 28 | } 29 | .alert:empty { 30 | display: none; 31 | } 32 | .invalid-feedback { 33 | color: #a94442; 34 | display: block; 35 | margin: -1rem 0 2rem; 36 | } 37 | 38 | /* LiveView specific classes for your customization */ 39 | .phx-no-feedback.invalid-feedback, 40 | .phx-no-feedback .invalid-feedback { 41 | display: none; 42 | } 43 | 44 | .phx-click-loading { 45 | opacity: 0.5; 46 | transition: opacity 1s ease-out; 47 | } 48 | 49 | .phx-loading { 50 | cursor: wait; 51 | } 52 | 53 | .phx-modal { 54 | opacity: 1 !important; 55 | position: fixed; 56 | z-index: 1; 57 | left: 0; 58 | top: 0; 59 | width: 100%; 60 | height: 100%; 61 | overflow: auto; 62 | background-color: rgba(0, 0, 0, 0.4); 63 | } 64 | 65 | .phx-modal-content { 66 | background-color: #fefefe; 67 | margin: 15vh auto; 68 | padding: 20px; 69 | border: 1px solid #888; 70 | width: min(90%, 600px); 71 | border-radius: 4px; 72 | } 73 | 74 | .phx-modal-close { 75 | color: #aaa; 76 | float: right; 77 | font-size: 28px; 78 | font-weight: bold; 79 | } 80 | 81 | .phx-modal-close:hover, 82 | .phx-modal-close:focus { 83 | color: black; 84 | text-decoration: none; 85 | cursor: pointer; 86 | } 87 | 88 | .fade-in-scale { 89 | animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys; 90 | } 91 | 92 | .fade-out-scale { 93 | animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys; 94 | } 95 | 96 | .fade-in { 97 | animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys; 98 | } 99 | .fade-out { 100 | animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys; 101 | } 102 | 103 | @keyframes fade-in-scale-keys { 104 | 0% { 105 | scale: 0.95; 106 | opacity: 0; 107 | } 108 | 100% { 109 | scale: 1; 110 | opacity: 1; 111 | } 112 | } 113 | 114 | @keyframes fade-out-scale-keys { 115 | 0% { 116 | scale: 1; 117 | opacity: 1; 118 | } 119 | 100% { 120 | scale: 0.95; 121 | opacity: 0; 122 | } 123 | } 124 | 125 | @keyframes fade-in-keys { 126 | 0% { 127 | opacity: 0; 128 | } 129 | 100% { 130 | opacity: 1; 131 | } 132 | } 133 | 134 | @keyframes fade-out-keys { 135 | 0% { 136 | opacity: 1; 137 | } 138 | 100% { 139 | opacity: 0; 140 | } 141 | } 142 | 143 | h2 { 144 | font-size: 1.5em; 145 | font-weight: bold; 146 | color: #533566; 147 | margin: 1em 0; 148 | } 149 | 150 | .end button { 151 | pointer-events: none; 152 | } 153 | 154 | @layer base { 155 | a { 156 | @apply text-red-400; 157 | } 158 | 159 | .phx-modal button { 160 | @apply bg-[#533566] text-white p-0 px-2 pb-1 rounded; 161 | } 162 | 163 | .phx-modal label { 164 | @apply flex items-center gap-2; 165 | } 166 | } 167 | 168 | .shake:not(.revealed) { 169 | animation: 0.2s ease-in-out 0s normal forwards 1 shake; 170 | } 171 | 172 | @keyframes shake { 173 | 0% { 174 | transform: rotate(0); 175 | } 176 | 177 | 25% { 178 | transform: rotate(15deg); 179 | } 180 | 181 | 50% { 182 | transform: rotate(0); 183 | } 184 | 185 | 75% { 186 | transform: rotate(-15deg); 187 | } 188 | 189 | 100% { 190 | transform: rotate(0); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /lib/mine_sweeper/cell_server.ex: -------------------------------------------------------------------------------- 1 | defmodule MineSweeper.CellServer do 2 | use GenServer, restart: :temporary 3 | 4 | alias MineSweeper.GameServer 5 | require Logger 6 | 7 | def via(slug, coords) do 8 | {:via, Registry, {GameRegistry, {:cell, slug, coords}}} 9 | end 10 | 11 | def start_link({slug, {coords, _}} = data) do 12 | GenServer.start_link(__MODULE__, data, name: via(slug, coords)) 13 | end 14 | 15 | def get(cell) do 16 | GenServer.call(cell, :get) 17 | end 18 | 19 | def reveal(cell) do 20 | GenServer.call(cell, :reveal) 21 | end 22 | 23 | defp reveal(cell, :chain) do 24 | GenServer.cast(cell, :chain) 25 | end 26 | 27 | defp reveal(cell, :death) do 28 | GenServer.cast(cell, :death) 29 | end 30 | 31 | def mark(cell) do 32 | GenServer.call(cell, :mark) 33 | end 34 | 35 | def detect(cell) do 36 | GenServer.call(cell, :detect) 37 | end 38 | 39 | @impl true 40 | def init({slug, {coords, data}}) do 41 | {:ok, %{slug: slug, coords: coords, data: Map.put(data, :opaque?, false)}} 42 | end 43 | 44 | @impl true 45 | def handle_call(:get, _from, state) do 46 | {:reply, state.data, state} 47 | end 48 | 49 | @impl true 50 | def handle_call(:mark, _from, %{data: %{revealed?: true}} = state) do 51 | {:reply, state.data, state} 52 | end 53 | 54 | @impl true 55 | def handle_call(:mark, _from, state) do 56 | broadcast_update(state) 57 | 58 | if state.data.marked? do 59 | MineSweeper.GameServer.mark(GameServer.via(state.slug), -1) 60 | else 61 | MineSweeper.GameServer.mark(GameServer.via(state.slug), 1) 62 | end 63 | 64 | data = %{state.data | marked?: !state.data.marked?} 65 | {:reply, data, %{state | data: data}} 66 | end 67 | 68 | @impl true 69 | def handle_call(:reveal, _from, %{data: %{revealed?: true}} = state) do 70 | {:reply, state.data, state} 71 | end 72 | 73 | @impl true 74 | def handle_call(:reveal, _from, state) do 75 | data = do_reveal(state, :chain) 76 | 77 | if state.data.marked? do 78 | MineSweeper.GameServer.mark(GameServer.via(state.slug), -1) 79 | end 80 | 81 | {:reply, data, %{state | data: data}} 82 | end 83 | 84 | @impl true 85 | def handle_call(:detect, _from, %{data: %{value: v}} = state) when is_integer(v) and v > 0 do 86 | {row, col} = state.coords 87 | 88 | for dr <- -1..1//1, dc <- -1..1//1, {dr, dc} != {0, 0} do 89 | {row + dr, col + dc} 90 | end 91 | |> Enum.map( 92 | &Task.async(fn -> 93 | GenServer.cast(via(state.slug, &1), {:marked?, self()}) 94 | 95 | receive do 96 | {:marked?, marked?} -> 97 | marked? 98 | after 99 | 100 -> false 100 | end 101 | end) 102 | ) 103 | |> Task.yield_many(20) 104 | |> Enum.map(fn {task, res} -> 105 | res || Task.shutdown(task, :brutal_kill) 106 | end) 107 | |> Enum.count_until( 108 | fn 109 | {:ok, true} -> true 110 | _ -> false 111 | end, 112 | state.data.value 113 | ) 114 | |> then(fn count -> 115 | if count >= state.data.value do 116 | for dr <- -1..1//1, dc <- -1..1//1, {dr, dc} != {0, 0} do 117 | GenServer.cast(via(state.slug, {row + dr, col + dc}), :chain) 118 | end 119 | end 120 | end) 121 | 122 | {:reply, :ok, state} 123 | end 124 | 125 | @impl true 126 | def handle_call(:detect, _from, state) do 127 | {:reply, :ok, state} 128 | end 129 | 130 | @impl true 131 | def handle_cast({:marked?, reply_to}, %{data: %{marked?: marked?}} = state) do 132 | send(reply_to, {:marked?, marked?}) 133 | {:noreply, state} 134 | end 135 | 136 | @impl true 137 | def handle_cast(:chain, %{data: %{marked?: true}} = state) do 138 | {:noreply, state} 139 | end 140 | 141 | @impl true 142 | def handle_cast(type, %{data: %{revealed?: true}} = state) when type in [:chain, :death] do 143 | {:noreply, state} 144 | end 145 | 146 | @impl true 147 | def handle_cast(type, state) when type in [:chain, :death] do 148 | data = do_reveal(state, type) 149 | {:noreply, %{state | data: data}} 150 | end 151 | 152 | defp do_reveal(state, type) do 153 | broadcast_update(state) 154 | MineSweeper.GameServer.reveal(GameServer.via(state.slug)) 155 | 156 | case {type, state.data.value} do 157 | {:chain, :mine} -> 158 | Task.start(fn -> 159 | MineSweeper.GameServer.explode(GameServer.via(state.slug)) 160 | end) 161 | 162 | {:chain, 0} -> 163 | Task.start(fn -> 164 | Process.sleep(30) 165 | chain(state, :chain) 166 | end) 167 | 168 | {:chain, _n} -> 169 | :ok 170 | end 171 | 172 | %{state.data | revealed?: true, marked?: false} 173 | end 174 | 175 | defp chain(state, type) do 176 | {row, col} = state.coords 177 | 178 | for dr <- -1..1//1, dc <- -1..1//1, {dr, dc} != {0, 0} do 179 | reveal(via(state.slug, {row + dr, col + dc}), type) 180 | end 181 | end 182 | 183 | defp broadcast_update(state) do 184 | Phoenix.PubSub.broadcast(MineSweeper.PubSub, state.slug, {:update, state.coords}) 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /lib/mine_sweeper/game_server.ex: -------------------------------------------------------------------------------- 1 | defmodule MineSweeper.GameServer do 2 | use GenServer, restart: :temporary 3 | 4 | require Logger 5 | 6 | def via(slug) do 7 | {:via, Registry, {GameRegistry, {:game, slug}}} 8 | end 9 | 10 | def start_link(opts) do 11 | slug = Keyword.fetch!(opts, :slug) 12 | GenServer.start_link(__MODULE__, opts, name: via(slug)) 13 | end 14 | 15 | def info(server) do 16 | GenServer.call(server, :info) 17 | end 18 | 19 | def time_limit(server) do 20 | GenServer.call(server, :time_limit) 21 | end 22 | 23 | def mark(server, diff) do 24 | GenServer.cast(server, {:mark, diff}) 25 | end 26 | 27 | def reveal(server) do 28 | GenServer.cast(server, :reveal) 29 | end 30 | 31 | def explode(server) do 32 | GenServer.cast(server, :explode) 33 | end 34 | 35 | @impl true 36 | def init(opts) do 37 | {slug, opts} = Keyword.pop(opts, :slug) 38 | {visibility, opts} = Keyword.pop(opts, :visibility) 39 | {time_limit, opts} = Keyword.pop(opts, :time_limit) 40 | 41 | Registry.register(RealmRegistry, visibility, slug) 42 | 43 | {:ok, 44 | %{ 45 | opts: opts, 46 | slug: slug, 47 | time: 0, 48 | mark_count: 0, 49 | reveal_count: 0, 50 | time_limit: time_limit, 51 | timer_ref: nil, 52 | cells_sup: nil, 53 | mines: nil 54 | }, {:continue, :init_game}} 55 | end 56 | 57 | @impl true 58 | def handle_continue(:init_game, state) do 59 | Logger.debug(init: state.slug, opts: state.opts) 60 | 61 | {:noreply, 62 | state 63 | |> setup_field() 64 | |> setup_timer()} 65 | end 66 | 67 | defp setup_field(state) do 68 | slug = state.slug 69 | width = Keyword.fetch!(state.opts, :width) 70 | height = Keyword.fetch!(state.opts, :height) 71 | mine_count = Keyword.fetch!(state.opts, :mine_count) 72 | 73 | field = 74 | for row <- 1..height, col <- 1..width do 75 | {{row, col}, %{revealed?: false, marked?: false, value: 0}} 76 | end 77 | 78 | {mines, cells} = 79 | field 80 | |> Enum.shuffle() 81 | |> Enum.split(mine_count) 82 | 83 | field = 84 | mines 85 | |> Enum.map(fn {coords, cell} -> {coords, %{cell | value: :mine, marked?: false}} end) 86 | |> Kernel.++(cells) 87 | |> Enum.into(%{}) 88 | 89 | field = 90 | Enum.reduce(mines, field, fn {{row, col}, _}, field -> 91 | for dr <- -1..1//1, 92 | dc <- -1..1//1, 93 | coords = {row + dr, col + dc}, 94 | is_map_key(field, coords), 95 | reduce: field do 96 | field -> 97 | Map.replace( 98 | field, 99 | coords, 100 | Map.update!(field[coords], :value, fn 101 | :mine -> :mine 102 | n -> n + 1 103 | end) 104 | ) 105 | end 106 | end) 107 | 108 | {:ok, cells_sup} = 109 | DynamicSupervisor.start_child(GameSupervisor, {DynamicSupervisor, strategy: :one_for_one}) 110 | 111 | for {coords, opts} <- field do 112 | DynamicSupervisor.start_child(cells_sup, {MineSweeper.CellServer, {slug, {coords, opts}}}) 113 | end 114 | 115 | %{state | cells_sup: cells_sup, mines: Enum.map(mines, &elem(&1, 0))} 116 | end 117 | 118 | defp setup_timer(state) do 119 | {:ok, ref} = :timer.send_interval(1000, self(), :tick) 120 | %{state | timer_ref: ref} 121 | end 122 | 123 | @impl true 124 | def handle_call(:info, _from, %{opts: opts} = state) do 125 | {:reply, 126 | { 127 | {opts[:width], opts[:height]}, 128 | state.time, 129 | {state.mark_count, opts[:mine_count]} 130 | }, state} 131 | end 132 | 133 | @impl true 134 | def handle_call(:time_limit, _from, state) do 135 | {:reply, state.time_limit, state} 136 | end 137 | 138 | @impl true 139 | def handle_cast({:mark, diff}, state) do 140 | mark_count = state.mark_count + diff 141 | Phoenix.PubSub.broadcast(MineSweeper.PubSub, state.slug, {:mark_count, mark_count}) 142 | {:noreply, %{state | mark_count: mark_count}} 143 | end 144 | 145 | @impl true 146 | def handle_cast(:reveal, state) do 147 | reveal_count = state.reveal_count + 1 148 | 149 | if state.opts[:width] * state.opts[:height] - state.opts[:mine_count] == reveal_count do 150 | Phoenix.PubSub.broadcast(MineSweeper.PubSub, state.slug, :win) 151 | {:stop, :shutdown, %{state | reveal_count: reveal_count}} 152 | else 153 | {:noreply, %{state | reveal_count: reveal_count}} 154 | end 155 | end 156 | 157 | @impl true 158 | def handle_cast(:explode, state) do 159 | Phoenix.PubSub.broadcast(MineSweeper.PubSub, state.slug, :lose) 160 | 161 | state.mines 162 | |> Enum.with_index() 163 | |> Enum.each(fn {coords, i} -> 164 | Task.start(fn -> 165 | Process.sleep(100 * i) 166 | MineSweeper.CellServer.reveal(MineSweeper.CellServer.via(state.slug, coords)) 167 | end) 168 | end) 169 | 170 | {:stop, :shutdown, state} 171 | end 172 | 173 | @impl true 174 | def handle_info(:tick, state) do 175 | time = state.time + 1 176 | Phoenix.PubSub.broadcast(MineSweeper.PubSub, state.slug, {:tick, time}) 177 | 178 | if time < state.time_limit do 179 | {:noreply, %{state | time: time}} 180 | else 181 | Phoenix.PubSub.broadcast(MineSweeper.PubSub, state.slug, :lose) 182 | {:stop, :shutdown, %{state | time: time}} 183 | end 184 | end 185 | 186 | @impl true 187 | def terminate(:shutdown, state) do 188 | :timer.cancel(state.timer_ref) 189 | :timer.kill_after(15_000, state.cells_sup) 190 | end 191 | end 192 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "castore": {:hex, :castore, "0.1.14", "3f6d7c7c1574c402fef29559d3f1a7389ba3524bc6a090a5e9e6abc3af65dcca", [:mix], [], "hexpm", "b34af542eadb727e6c8b37fdf73e18b2e02eb483a4ea0b52fd500bc23f052b7b"}, 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.1", "6411f6e23f1a8b68a82fa3a36366d4881f21f47fc79a9efb8c615e62050219da", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ea36d226ec5999781a9a8ad64e5d8c4454ecedc7a4d643e4832bf08efca01f00"}, 8 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, 9 | "ecto": {:hex, :ecto, "3.7.1", "a20598862351b29f80f285b21ec5297da1181c0442687f9b8329f0445d228892", [: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", "d36e5b39fc479e654cffd4dbe1865d9716e4a9b6311faff799b6f90ab81b8638"}, 10 | "ecto_sql": {:hex, :ecto_sql, "3.7.1", "8de624ef50b2a8540252d8c60506379fbbc2707be1606853df371cf53df5d053", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.4.0 or ~> 0.5.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.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", "2b42a32e2ce92f64aba5c88617891ab3b0ba34f3f3a503fa20009eae1a401c81"}, 11 | "esbuild": {:hex, :esbuild, "0.4.0", "9f17db148aead4cf1e6e6a584214357287a93407b5fb51a031f122b61385d4c2", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "b61e4e6b92ffe45e4ee4755a22de6211a67c67987dc02afb35a425a0add1d447"}, 12 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 13 | "floki": {:hex, :floki, "0.32.0", "f915dc15258bc997d49be1f5ef7d3992f8834d6f5695270acad17b41f5bcc8e2", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "1c5a91cae1fd8931c26a4826b5e2372c284813904c8bacb468b5de39c7ececbd"}, 14 | "gettext": {:hex, :gettext, "0.19.0", "6909d61b38bb33339558f128f8af5913d5d5fe304a770217bf352b1620fb7ec4", [:mix], [], "hexpm", "3f7a274f52ebda9bb6655dfeda3d6b0dc4537ae51ce41dcccc7f73ca7379ad5e"}, 15 | "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, 16 | "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, 17 | "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, 18 | "phoenix": {:hex, :phoenix, "1.6.6", "281c8ce8dccc9f60607346b72cdfc597c3dde134dd9df28dff08282f0b751754", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.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", "807bd646e64cd9dc83db016199715faba72758e6db1de0707eef0a2da4924364"}, 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.2", "0769470265eb13af01b5001b29cb935f4710d6adaa1ffc18417a570a337a2f0f", [: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.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5bc6c6b38a2ca8b5020b442322fcee6afd5e641637a0b1fb059d4bd89bc58e7b"}, 22 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.3", "3a53772a6118d5679bf50fc1670505a290e32a1d195df9e069d8c53ab040c054", [: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", "766796676e5f558dbae5d1bdb066849673e956005e3730dfd5affd7a6da4abac"}, 23 | "phoenix_live_view": {:hex, :phoenix_live_view, "0.17.5", "63f52a6f9f6983f04e424586ff897c016ecc5e4f8d1e2c22c2887af1c57215d8", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.9 or ~> 1.6.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", "c5586e6a3d4df71b8214c769d4f5eb8ece2b4001711a7ca0f97323c36958b0e3"}, 24 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, 25 | "phoenix_view": {:hex, :phoenix_view, "1.1.0", "149f053830ec3c19a2a8a67c208885a26e4c2b92cc4a9d54e03b633d68ef9add", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "dd219f768b3d97a224ed11e8a83f4fd0f3bd490434d3950d7c51a2e597a762f1"}, 26 | "plug": {:hex, :plug, "1.12.1", "645678c800601d8d9f27ad1aebba1fdb9ce5b2623ddb961a074da0b96c35187d", [: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", "d57e799a777bc20494b784966dc5fbda91eb4a09f571f76545b72a634ce0d30b"}, 27 | "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, 28 | "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, 29 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 30 | "tailwind": {:hex, :tailwind, "0.1.4", "be3c48382a47e9d43262eb169ac22f7d3b035d7e30a7b3ebd46de57eb8527dbf", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "cf648462dcbf006da3bdf95dc7dd87d2fb44d8bcc3bb59ab61a2410d0edb09bb"}, 31 | "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"}, 32 | "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, 33 | "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, 34 | } 35 | --------------------------------------------------------------------------------