├── .credo.exs ├── native └── linkequipment_lychee │ ├── .gitignore │ ├── .cargo │ └── config.toml │ ├── Cargo.toml │ ├── README.md │ └── src │ └── lib.rs ├── .tool-versions ├── priv ├── repo │ ├── migrations │ │ ├── .formatter.exs │ │ ├── 20240721214030_add_links_table.exs │ │ └── 20240719214509_add_oban_jobs_table.exs │ └── seeds.exs └── static │ ├── favicon.ico │ ├── robots.txt │ └── images │ └── logo.svg ├── lib ├── test_helper.exs ├── link_equipment_web │ ├── components │ │ ├── components.ex │ │ ├── layouts │ │ │ ├── app.html.heex │ │ │ └── root.html.heex │ │ ├── layouts.ex │ │ ├── living_source_component.ex │ │ ├── raw_link_live_component.ex │ │ ├── link_live_component.ex │ │ ├── living_source_live_component.ex │ │ └── core_components.ex │ ├── controllers │ │ ├── error_json.ex │ │ └── error_html.ex │ ├── live │ │ ├── v3_live.ex │ │ ├── home_live.ex │ │ └── source_live.ex │ ├── router.ex │ ├── endpoint.ex │ └── telemetry.ex ├── link_equipment │ ├── link │ │ ├── repo_test.exs │ │ ├── repo.ex │ │ └── factory.ex │ ├── lychee.ex │ ├── link_test.exs │ ├── service.ex │ ├── source_manager.ex │ ├── scan_manager.ex │ ├── status_manager.ex │ ├── link.ex │ ├── application.ex │ ├── repo.ex │ ├── raw_link.ex │ └── factory.ex ├── link_equipment.ex ├── link_equipment_web.ex └── util.ex ├── assets ├── package.json ├── css │ ├── scale.css │ └── app.css ├── vendor │ └── topbar.js ├── js │ └── app.js └── package-lock.json ├── .formatter.exs ├── .devcontainer ├── README.md ├── docker-compose.yml ├── devcontainer.json └── Dockerfile ├── README.md ├── config ├── prod.exs ├── test.exs ├── config.exs ├── dev.exs └── runtime.exs ├── .gitignore ├── test └── support │ ├── conn_case.ex │ └── data_case.ex ├── .github └── workflows │ └── ci.yml ├── mix.exs └── mix.lock /.credo.exs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /native/linkequipment_lychee/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.17.2 2 | erlang 27.0.1 3 | rust 1.76.0 4 | nodejs 22.10.0 5 | -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SilvanCodes/link_equipment/main/priv/static/favicon.ico -------------------------------------------------------------------------------- /lib/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Ecto.Adapters.SQL.Sandbox.mode(LinkEquipment.Repo, :manual) 3 | 4 | Faker.start() 5 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@shikijs/transformers": "^1.22.2", 4 | "shiki": "^1.22.1" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /native/linkequipment_lychee/.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.'cfg(target_os = "macos")'] 2 | rustflags = [ 3 | "-C", "link-arg=-undefined", 4 | "-C", "link-arg=dynamic_lookup", 5 | ] 6 | -------------------------------------------------------------------------------- /lib/link_equipment_web/components/components.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipmentWeb.Components do 2 | @moduledoc false 3 | defdelegate living_source(assigns), to: LinkEquipmentWeb.LivingSourceComponent, as: :render 4 | end 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :ecto_sql, :phoenix], 3 | subdirectories: ["priv/*/migrations"], 4 | plugins: [Phoenix.LiveView.HTMLFormatter, Styler], 5 | inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] 6 | ] 7 | -------------------------------------------------------------------------------- /lib/link_equipment/link/repo_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipment.Link.RepoTest do 2 | use LinkEquipment.DataCase 3 | 4 | alias LinkEquipment.Link 5 | 6 | test "insert/1" do 7 | link = Link.Factory.build() 8 | 9 | assert {:ok, _} = Link.Repo.insert(link) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/link_equipment.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipment do 2 | @moduledoc """ 3 | LinkEquipment keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /lib/link_equipment_web/components/layouts/app.html.heex: -------------------------------------------------------------------------------- 1 | <.cover style="min-block-size: 100vh;"> 2 |
3 | <.center> 4 | LINK EQUIPMENT 5 | 6 |
7 |
8 | <.center> 9 | <.flash_group flash={@flash} /> 10 | <%= @inner_content %> 11 | 12 |
13 | 14 | -------------------------------------------------------------------------------- /lib/link_equipment/link/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipment.Link.Repo do 2 | alias Ecto.Changeset 3 | alias LinkEquipment.Link 4 | 5 | @spec insert(Link.t() | Changeset.t()) :: {:ok, Link.t()} | {:error, Changeset.t()} 6 | defdelegate insert(link_or_changeset), to: LinkEquipment.Repo 7 | 8 | def query(query), do: LinkEquipment.Repo.all(query) 9 | end 10 | -------------------------------------------------------------------------------- /native/linkequipment_lychee/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "linkequipment_lychee" 3 | version = "0.1.0" 4 | authors = [] 5 | edition = "2021" 6 | 7 | [lib] 8 | name = "linkequipment_lychee" 9 | path = "src/lib.rs" 10 | crate-type = ["cdylib"] 11 | 12 | [dependencies] 13 | lychee-lib = "0.15.1" 14 | reqwest = "0.12.5" 15 | rustler = "0.34.0" 16 | tokio = "1.38.1" 17 | tokio-stream = "0.1.15" 18 | url = "2.5.2" 19 | -------------------------------------------------------------------------------- /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 | # LinkEquipment.Repo.insert!(%LinkEquipment.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /.devcontainer/README.md: -------------------------------------------------------------------------------- 1 | # Ahoi, welcome on board! 2 | 3 | ## Environment 4 | As environment variables often contain sensitive information like API or license keys it is recomended to not have them version controlled: 5 | ~~~.gitignore 6 | # never check in .env files, add to .gitignore: 7 | *.env 8 | ~~~ 9 | 10 | To configure your local environment, checkout the repo and create a file called `devcontainer.env` inside the `.devcontainer` folder. -------------------------------------------------------------------------------- /priv/repo/migrations/20240721214030_add_links_table.exs: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipment.Repo.Migrations.AddLinksTable do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:links) do 6 | add :url, :map, null: false 7 | add :source_document_url, :map, null: false 8 | add :html_element, :string, null: true 9 | add :element_attribute, :string, null: true 10 | 11 | timestamps() 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /priv/repo/migrations/20240719214509_add_oban_jobs_table.exs: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipment.Repo.Migrations.AddObanJobsTable do 2 | use Ecto.Migration 3 | 4 | def up do 5 | Oban.Migration.up(version: 12) 6 | end 7 | 8 | # We specify `version: 1` in `down`, ensuring that we'll roll all the way back down if 9 | # necessary, regardless of which version we've migrated `up` to. 10 | def down do 11 | Oban.Migration.down(version: 1) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/link_equipment_web/components/layouts.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipmentWeb.Layouts do 2 | @moduledoc """ 3 | This module holds different layouts used by your application. 4 | 5 | See the `layouts` directory for all templates available. 6 | The "root" layout is a skeleton rendered as part of the 7 | application router. The "app" layout is set as the default 8 | layout on both `use LinkEquipmentWeb, :controller` and 9 | `use LinkEquipmentWeb, :live_view`. 10 | """ 11 | use LinkEquipmentWeb, :html 12 | 13 | embed_templates "layouts/*" 14 | end 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | `mix phx.new . --database sqlite3 --binary-id --no-tailwind --verbose --no-mailer --no-gettext` 2 | 3 | `export ERL_AFLAGS="-kernel shell_history enabled"` 4 | 5 | 6 | ## LiveView state handling concept 7 | 8 | Idea: 9 | - all permanent state is passed via URL 10 | - every other state needs to be derivable from whats passed in url 11 | - use a generic `to_form(params)` in handle_params to create state 12 | - maybe initialize assigns to nil in `mount` if necessary 13 | - actually assign individual assigns from url state once validated 14 | - how to do form validation? 15 | -------------------------------------------------------------------------------- /lib/link_equipment/lychee.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipment.Lychee do 2 | @moduledoc false 3 | use Rustler, 4 | otp_app: :link_equipment, 5 | crate: :linkequipment_lychee 6 | 7 | alias Util.Result 8 | 9 | # When your NIF is loaded, it will override this function. 10 | @spec collect_links(String.t()) :: Result.t(list(LinkEquipment.Link.t())) 11 | def collect_links(_url), do: :erlang.nif_error(:nif_not_loaded) 12 | 13 | @spec extract_links(String.t()) :: list(LinkEquipment.RawLink.t()) 14 | def extract_links(_source), do: :erlang.nif_error(:nif_not_loaded) 15 | end 16 | -------------------------------------------------------------------------------- /native/linkequipment_lychee/README.md: -------------------------------------------------------------------------------- 1 | # NIF for Elixir.LinkEquipment.Lychee 2 | 3 | ## To build the NIF module: 4 | 5 | - Your NIF will now build along with your project. 6 | 7 | ## To load the NIF: 8 | 9 | ```elixir 10 | defmodule LinkEquipment.Lychee do 11 | use Rustler, otp_app: :link_equipment, crate: "linkequipment_lychee" 12 | 13 | # When your NIF is loaded, it will override this function. 14 | def add(_a, _b), do: :erlang.nif_error(:nif_not_loaded) 15 | end 16 | ``` 17 | 18 | ## Examples 19 | 20 | [This](https://github.com/rusterlium/NifIo) is a complete example of a NIF written in Rust. 21 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Note we also include the path to a cache manifest 4 | # containing the digested version of static files. This 5 | # manifest is generated by the `mix assets.deploy` task, 6 | # which you should run after static files are built and 7 | # before starting your production server. 8 | config :link_equipment, LinkEquipmentWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" 9 | 10 | # Do not print debug messages in production 11 | config :logger, level: :info 12 | 13 | # Runtime production configuration, including reading 14 | # of environment variables, is done on config/runtime.exs. 15 | -------------------------------------------------------------------------------- /lib/link_equipment_web/components/layouts/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <.live_title suffix=" · Phoenix Framework"> 8 | <%= assigns[:page_title] || "LinkEquipment" %> 9 | 10 | 11 | 13 | 14 | 15 | <%= @inner_content %> 16 | 17 | 18 | -------------------------------------------------------------------------------- /lib/link_equipment/link_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipment.LinkTest do 2 | use LinkEquipment.DataCase 3 | 4 | alias LinkEquipment.Link 5 | 6 | test "all_from_source/1" do 7 | wanted_source = URI.parse(Faker.Internet.url()) 8 | other_source = URI.parse(Faker.Internet.url()) 9 | 10 | {:ok, _} = insert(Link, %{source_document_url: wanted_source}) 11 | {:ok, _} = insert(Link, %{source_document_url: wanted_source}) 12 | 13 | insert(Link, %{source_document_url: other_source}) 14 | 15 | assert [%{source_document_url: ^wanted_source}, %{source_document_url: ^wanted_source}] = 16 | wanted_source |> Link.all_from_source() |> Link.Repo.query() 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/link_equipment_web/components/living_source_component.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipmentWeb.LivingSourceComponent do 2 | @moduledoc false 3 | use LinkEquipmentWeb, :html 4 | 5 | def render(assigns) do 6 | ~H""" 7 | <.async :let={source} :if={@source} assign={@source}> 8 | <:loading> 9 |

Getting source...

10 | 11 |
12 |
13 |         
14 |     <%= source %>
15 |         
16 |       
17 |
18 | <:failed :let={_failure}> 19 |

There was an error getting the source. :(

20 | 21 | 22 | """ 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/link_equipment_web/controllers/error_json.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipmentWeb.ErrorJSON do 2 | @moduledoc """ 3 | This module is invoked by your endpoint in case of errors on JSON requests. 4 | 5 | See config/config.exs. 6 | """ 7 | 8 | # If you want to customize a particular status code, 9 | # you may add your own clauses, such as: 10 | # 11 | # def render("500.json", _assigns) do 12 | # %{errors: %{detail: "Internal Server Error"}} 13 | # end 14 | 15 | # By default, Phoenix returns the status message from 16 | # the template name. For example, "404.json" becomes 17 | # "Not Found". 18 | def render(template, _assigns) do 19 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/link_equipment/service.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipment.Service do 2 | @moduledoc false 3 | 4 | @doc """ 5 | The action the service performs. 6 | 7 | Is automatically wrapped in a transaction. 8 | """ 9 | @callback run(keyword()) :: {:ok, any()} | {:error, any()} 10 | 11 | defmacro __using__(_opts \\ []) do 12 | behaviour = __MODULE__ 13 | 14 | quote bind_quoted: [behaviour: behaviour] do 15 | @behaviour behaviour 16 | 17 | @before_compile behaviour 18 | 19 | defoverridable behaviour 20 | end 21 | end 22 | 23 | defmacro __before_compile__(_env) do 24 | quote do 25 | defoverridable(run: 1) 26 | 27 | def run(args) do 28 | LinkEquipment.Repo.transaction(super(args)) 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/link_equipment_web/controllers/error_html.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipmentWeb.ErrorHTML do 2 | @moduledoc """ 3 | This module is invoked by your endpoint in case of errors on HTML requests. 4 | 5 | See config/config.exs. 6 | """ 7 | use LinkEquipmentWeb, :html 8 | 9 | # If you want to customize your error pages, 10 | # uncomment the embed_templates/1 call below 11 | # and add pages to the error directory: 12 | # 13 | # * lib/link_equipment_web/controllers/error_html/404.html.heex 14 | # * lib/link_equipment_web/controllers/error_html/500.html.heex 15 | # 16 | # embed_templates "error_html/*" 17 | 18 | # The default is to render a plain text page based on 19 | # the template name. For example, "404.html" becomes 20 | # "Not Found". 21 | def render(template, _assigns) do 22 | Phoenix.Controller.status_message_from_template(template) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | # Update this to the name of the service you want to work with in your docker-compose.yml file 3 | devcontainer: 4 | build: 5 | context: .. 6 | dockerfile: .devcontainer/Dockerfile 7 | 8 | env_file: 9 | - path: devcontainer.env 10 | required: false 11 | 12 | volumes: 13 | # Update this to wherever you want VS Code to mount the folder of your project 14 | - ../..:/workspaces:cached 15 | # Uncomment the next line to use Docker from inside the container. See https://aka.ms/vscode-remote/samples/docker-from-docker-compose for details. 16 | - /var/run/docker.sock:/var/run/docker.sock 17 | # Store command history persistently 18 | - commandhistory:/commandhistory 19 | # Overrides default command so things don't shut down after the process ends. 20 | command: sleep infinity 21 | 22 | volumes: 23 | commandhistory: 24 | -------------------------------------------------------------------------------- /lib/link_equipment/source_manager.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipment.SourceManager do 2 | @moduledoc false 3 | 4 | alias Util.Result 5 | 6 | @cache_name :source_cache 7 | 8 | @spec check_source(URI.t()) :: Result.t(String.t()) 9 | def check_source(url) do 10 | case Cachex.fetch(@cache_name, url, fn url -> get_source(url) end) do 11 | {:ignore, {:error, error}} -> 12 | {:error, error} 13 | 14 | {:ignore, body} -> 15 | {:ok, body} 16 | 17 | {:ok, body} -> 18 | {:ok, body} 19 | 20 | {:commit, body} -> 21 | {:ok, body} 22 | end 23 | end 24 | 25 | defp get_source(url) do 26 | case Req.get(url) do 27 | {:ok, response} -> 28 | if response.status == 200 do 29 | {:commit, response.body, expire: :timer.minutes(5)} 30 | else 31 | {:ignore, response.body} 32 | end 33 | 34 | error -> 35 | {:ignore, error} 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/link_equipment/scan_manager.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipment.ScanManager do 2 | @moduledoc false 3 | 4 | alias LinkEquipment.Link 5 | alias Util.Result 6 | 7 | @cache_name :scan_cache 8 | 9 | @spec check_scan(URI.t()) :: Result.t([Link.t()]) 10 | def check_scan(url) do 11 | case Cachex.fetch(@cache_name, url, fn url -> get_scan(url) end) do 12 | {:ignore, {:error, error}} -> 13 | {:error, error} 14 | 15 | {:ignore, results} -> 16 | {:ok, results} 17 | 18 | {:ok, results} -> 19 | {:ok, results} 20 | 21 | {:commit, results} -> 22 | {:ok, results} 23 | end 24 | end 25 | 26 | defp get_scan(url) do 27 | IO.puts("HIT NETWORK") 28 | 29 | case LinkEquipment.Lychee.collect_links(url) do 30 | {:ok, results} -> 31 | # We could group here if it turns out to be interesting when a resource is linked multiple times in different places. 32 | {:commit, results |> Enum.sort() |> Enum.uniq_by(& &1.url)} 33 | 34 | error -> 35 | {:ignore, error} 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/link_equipment/status_manager.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipment.StatusManager do 2 | @moduledoc false 3 | 4 | @cache_name :status_cache 5 | def check_status(url) do 6 | case Cachex.fetch(@cache_name, url, fn url -> get_status(url) end) do 7 | {:ignore, {:error, error}} -> 8 | {:error, error} 9 | 10 | {:ignore, status} -> 11 | {:ok, status} 12 | 13 | {:ok, status} -> 14 | {:ok, status} 15 | 16 | {:commit, status} -> 17 | {:ok, status} 18 | end 19 | end 20 | 21 | @doc """ 22 | We only cache status 200 codes as they should be the majority and are anticipated to be least likely to change. 23 | """ 24 | defp get_status(url) do 25 | case request(url) do 26 | {:ok, %{status: status}} -> 27 | if status in 200..299 do 28 | {:commit, status, expire: :timer.minutes(3)} 29 | else 30 | {:ignore, status} 31 | end 32 | 33 | error -> 34 | {:ignore, error} 35 | end 36 | end 37 | 38 | defp request(url) do 39 | with {:ok, %{status: 405}} <- Req.head(url) do 40 | Req.get(url) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/link_equipment/link.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipment.Link do 2 | @moduledoc false 3 | 4 | use Ecto.Schema 5 | 6 | import Ecto.Query 7 | 8 | alias Ecto.Changeset 9 | alias LinkEquipment.Repo.EctoURI 10 | 11 | @timestamps_opts [type: :utc_datetime] 12 | 13 | @type t :: %__MODULE__{ 14 | url: URI, 15 | source_document_url: URI, 16 | html_element: String.t() | nil, 17 | element_attribute: String.t() | nil 18 | } 19 | 20 | schema "links" do 21 | field :url, EctoURI 22 | field :source_document_url, EctoURI 23 | field :html_element, :string 24 | field :element_attribute, :string 25 | 26 | timestamps() 27 | end 28 | 29 | def changeset(link \\ %__MODULE__{}, params \\ %{}) do 30 | link 31 | |> Changeset.cast(params, [:url, :source_document_url, :html_element, :element_attribute]) 32 | |> Changeset.validate_required([:url, :source_document_url]) 33 | end 34 | 35 | def all, do: __MODULE__ 36 | 37 | def all_from_source(query \\ all(), source_document_url) do 38 | from(link in query, where: link.source_document_url == ^source_document_url) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | # 5 | # The MIX_TEST_PARTITION environment variable can be used 6 | # to provide built-in test partitioning in CI environment. 7 | # Run `mix help test` for more information. 8 | config :link_equipment, LinkEquipment.Repo, 9 | database: Path.expand("../link_equipment_test.db", __DIR__), 10 | pool_size: 5, 11 | pool: Ecto.Adapters.SQL.Sandbox 12 | 13 | # We don't run a server during test. If one is required, 14 | # you can enable the server option below. 15 | config :link_equipment, LinkEquipmentWeb.Endpoint, 16 | http: [ip: {127, 0, 0, 1}, port: 4002], 17 | secret_key_base: "9nn3olMORdRoH8I2PCBRLi6OgAeARxgPGrjgBI1twYqQvZuvtnWkrOEGrzwLgdTq", 18 | server: false 19 | 20 | config :link_equipment, Oban, testing: :inline 21 | 22 | # Print only warnings and errors during test 23 | config :logger, level: :warning 24 | 25 | # Initialize plugs at runtime for faster test compilation 26 | config :phoenix, :plug_init_mode, :runtime 27 | 28 | # Enable helpful, but potentially expensive runtime checks 29 | config :phoenix_live_view, 30 | enable_expensive_runtime_checks: true 31 | 32 | # config/test.exs 33 | -------------------------------------------------------------------------------- /.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 | # Temporary files, for example, from tests. 23 | /tmp/ 24 | 25 | # Ignore package tarball (built via "mix hex.build"). 26 | link_equipment-*.tar 27 | 28 | # Ignore assets that are produced by build tools. 29 | /priv/static/assets/ 30 | /priv/native/ 31 | 32 | # Ignore digested assets cache. 33 | /priv/static/cache_manifest.json 34 | 35 | # In case you use Node.js/npm, you want to ignore these. 36 | npm-debug.log 37 | /assets/node_modules/ 38 | 39 | # Database files 40 | *.db 41 | *.db-* 42 | 43 | # vscode config 44 | .vscode 45 | 46 | # environment variables 47 | *.env 48 | 49 | # language server 50 | .elixir_ls -------------------------------------------------------------------------------- /assets/css/scale.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* Ratios for maintaining visual consistency. */ 3 | --ratio: 1.33; 4 | 5 | --s-5: calc(var(--s-4) / var(--ratio)); 6 | --s-4: calc(var(--s-3) / var(--ratio)); 7 | --s-3: calc(var(--s-2) / var(--ratio)); 8 | --s-2: calc(var(--s-1) / var(--ratio)); 9 | --s-1: calc(var(--s0) / var(--ratio)); 10 | --s0: 1rem; 11 | --s1: calc(var(--s0) * var(--ratio)); 12 | --s2: calc(var(--s1) * var(--ratio)); 13 | --s3: calc(var(--s2) * var(--ratio)); 14 | --s4: calc(var(--s3) * var(--ratio)); 15 | --s5: calc(var(--s4) * var(--ratio)); 16 | 17 | --t-5: calc(var(--t-4) / var(--ratio)); 18 | --t-4: calc(var(--t-3) / var(--ratio)); 19 | --t-3: calc(var(--t-2) / var(--ratio)); 20 | --t-2: calc(var(--t-1) / var(--ratio)); 21 | --t-1: calc(var(--t0) / var(--ratio)); 22 | --t0: 1000ms; 23 | --t1: calc(var(--t0) * var(--ratio)); 24 | --t2: calc(var(--t1) * var(--ratio)); 25 | --t3: calc(var(--t2) * var(--ratio)); 26 | --t4: calc(var(--t3) * var(--ratio)); 27 | --t5: calc(var(--t4) * var(--ratio)); 28 | 29 | /* Zero value*/ 30 | --zero: 0; 31 | 32 | /* Measure width (characters per line) */ 33 | --measure: 60ch; 34 | } -------------------------------------------------------------------------------- /lib/link_equipment_web/live/v3_live.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipmentWeb.V3Live do 2 | @moduledoc false 3 | use LinkEquipmentWeb, :live_view 4 | 5 | alias LinkEquipmentWeb.LivingSourceLiveComponent 6 | 7 | def mount(_params, _session, socket) do 8 | ok(socket) 9 | end 10 | 11 | def handle_params(params, _uri, socket) do 12 | socket 13 | |> assign_params(params) 14 | |> noreply() 15 | end 16 | 17 | def handle_event("scan", params, socket) do 18 | params = 19 | params 20 | |> Map.take(["source_url"]) 21 | |> merge_params(socket) 22 | 23 | socket 24 | |> push_patch(to: configured_path(params), replace: true) 25 | |> noreply() 26 | end 27 | 28 | defp configured_path(params), do: ~p"/v3?#{params}" 29 | 30 | def render(assigns) do 31 | ~H""" 32 | <.stack> 33 | <.center> 34 | <.form for={@params} phx-change="scan"> 35 | <.input type="text" field={@params[:source_url]} label="URL:" /> 36 | 37 | 38 | 39 | <.live_component 40 | :if={source_url = @params[:source_url].value} 41 | id={:base64.encode(source_url)} 42 | module={LivingSourceLiveComponent} 43 | source_url={source_url} 44 | /> 45 | 46 | """ 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipmentWeb.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 LinkEquipmentWeb.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 | use LinkEquipmentWeb, :verified_routes 23 | 24 | # Import conveniences for testing with connections 25 | import LinkEquipmentWeb.ConnCase 26 | import Phoenix.ConnTest 27 | import Plug.Conn 28 | 29 | # The default endpoint for testing 30 | @endpoint LinkEquipmentWeb.Endpoint 31 | end 32 | end 33 | 34 | setup tags do 35 | LinkEquipment.DataCase.setup_sandbox(tags) 36 | {:ok, conn: Phoenix.ConnTest.build_conn()} 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/link_equipment/link/factory.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipment.Link.Factory do 2 | @moduledoc false 3 | use LinkEquipment.Factory 4 | 5 | alias LinkEquipment.Link 6 | 7 | @type fields :: %{ 8 | optional(:url) => URI.t() | String.t(), 9 | optional(:source_document_url) => URI.t() | String.t(), 10 | optional(:html_element) => String.t() | nil, 11 | optional(:element_attribute) => String.t() | nil 12 | } 13 | 14 | @type opts :: [html_element: :a_tag | :audio_tag] 15 | 16 | @spec build(fields(), opts()) :: Link.t() 17 | def build(fields \\ %{}, opts \\ [html_element: :a_tag]) do 18 | fields = 19 | fields 20 | |> Map.put_new(:url, Faker.Internet.url()) 21 | |> Map.update!(:url, &URI.parse/1) 22 | |> Map.put_new(:source_document_url, Faker.Internet.url()) 23 | |> Map.update!(:source_document_url, &URI.parse/1) 24 | |> html_element(opts[:html_element]) 25 | 26 | struct(Link, fields) 27 | end 28 | 29 | defp html_element(fields, nil), do: fields 30 | 31 | defp html_element(fields, :a_tag), 32 | do: fields |> Map.put_new(:html_element, "a") |> Map.put_new(:element_attribute, "href") 33 | 34 | defp html_element(fields, :audio_tag), 35 | do: fields |> Map.put_new(:html_element, "audio") |> Map.put_new(:element_attribute, "src") 36 | end 37 | -------------------------------------------------------------------------------- /lib/link_equipment_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipmentWeb.Router do 2 | use LinkEquipmentWeb, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_live_flash 8 | plug :put_root_layout, html: {LinkEquipmentWeb.Layouts, :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 "/", LinkEquipmentWeb do 18 | pipe_through :browser 19 | 20 | live "/v1", HomeLive, :default 21 | 22 | live "/v2", SourceLive, :default 23 | 24 | live "/v3", V3Live, :default 25 | end 26 | 27 | # Other scopes may use custom stacks. 28 | # scope "/api", LinkEquipmentWeb do 29 | # pipe_through :api 30 | # end 31 | 32 | # Enable LiveDashboard in development 33 | if Application.compile_env(:link_equipment, :dev_routes) do 34 | # If you want to use the LiveDashboard in production, you should put 35 | # it behind authentication and allow only admins to access it. 36 | # If your application does not have an admins-only section yet, 37 | # you can use Plug.BasicAuth to set up some basic authentication 38 | # as long as you are also using SSL (which you should anyway). 39 | import Phoenix.LiveDashboard.Router 40 | 41 | scope "/dev" do 42 | pipe_through :browser 43 | 44 | live_dashboard "/dashboard", metrics: LinkEquipmentWeb.Telemetry 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "dockerComposeFile": "docker-compose.yml", 3 | "service": "devcontainer", 4 | "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", 5 | "remoteUser": "gitpod", 6 | "customizations": { 7 | "vscode": { 8 | "settings": { 9 | "files.autoSave": "onFocusChange", 10 | "editor.formatOnSave": true, 11 | "gitlens": { 12 | "showWelcomeOnInstall": false, 13 | "showWhatsNewAfterUpgrades": false 14 | }, 15 | "terminal.integrated.scrollback": 10000, 16 | "emmet": { 17 | "triggerExpansionOnTab": true, 18 | "includeLanguages": { 19 | "phoenix-heex": "html" 20 | } 21 | }, 22 | "rust-analyzer.linkedProjects": [ 23 | "./native/linkequipment_lychee/Cargo.toml" 24 | ] 25 | }, 26 | "extensions": [ 27 | "ms-azuretools.vscode-docker", 28 | "eamodio.gitlens", 29 | "mikestead.dotenv", 30 | "JakeBecker.elixir-ls", 31 | "pantajoe.vscode-elixir-credo", 32 | "phoenixframework.phoenix", 33 | "yzhang.markdown-all-in-one", 34 | "cweijan.vscode-database-client2", 35 | "humao.rest-client", 36 | "GitHub.vscode-github-actions", 37 | "mechatroner.rainbow-csv", 38 | "rust-lang.rust-analyzer" 39 | ] 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /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 | # Configure esbuild (the version is required) 11 | config :esbuild, 12 | version: "0.17.11", 13 | link_equipment: [ 14 | args: ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), 15 | cd: Path.expand("../assets", __DIR__), 16 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 17 | ] 18 | 19 | config :flop, repo: LinkEquipment.Repo 20 | 21 | # Configures the endpoint 22 | config :link_equipment, LinkEquipmentWeb.Endpoint, 23 | url: [host: "localhost"], 24 | adapter: Bandit.PhoenixAdapter, 25 | render_errors: [ 26 | formats: [html: LinkEquipmentWeb.ErrorHTML, json: LinkEquipmentWeb.ErrorJSON], 27 | layout: false 28 | ], 29 | pubsub_server: LinkEquipment.PubSub, 30 | live_view: [signing_salt: "7wNXycBX"] 31 | 32 | config :link_equipment, Oban, 33 | engine: Oban.Engines.Lite, 34 | queues: [default: 10], 35 | repo: LinkEquipment.Repo 36 | 37 | config :link_equipment, 38 | ecto_repos: [LinkEquipment.Repo], 39 | generators: [timestamp_type: :utc_datetime, binary_id: true] 40 | 41 | # Configures Elixir's Logger 42 | config :logger, :console, 43 | format: "$time $metadata[$level] $message\n", 44 | metadata: [:request_id] 45 | 46 | # Use Jason for JSON parsing in Phoenix 47 | config :phoenix, :json_library, Jason 48 | 49 | # Import environment specific config. This must remain at the bottom 50 | # of this file so it overrides the configuration defined above. 51 | import_config "#{config_env()}.exs" 52 | -------------------------------------------------------------------------------- /lib/link_equipment/application.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipment.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | children = [ 11 | LinkEquipmentWeb.Telemetry, 12 | LinkEquipment.Repo, 13 | {Oban, Application.fetch_env!(:link_equipment, Oban)}, 14 | {Ecto.Migrator, repos: Application.fetch_env!(:link_equipment, :ecto_repos), skip: skip_migrations?()}, 15 | {DNSCluster, query: Application.get_env(:link_equipment, :dns_cluster_query) || :ignore}, 16 | {Phoenix.PubSub, name: LinkEquipment.PubSub}, 17 | Supervisor.child_spec({Cachex, [:status_cache]}, id: :status_cache), 18 | Supervisor.child_spec({Cachex, [:source_cache]}, id: :source_cache), 19 | Supervisor.child_spec({Cachex, [:scan_cache]}, id: :scan_cache), 20 | # Start a worker by calling: LinkEquipment.Worker.start_link(arg) 21 | # {LinkEquipment.Worker, arg}, 22 | # Start to serve requests, typically the last entry 23 | LinkEquipmentWeb.Endpoint 24 | ] 25 | 26 | # See https://hexdocs.pm/elixir/Supervisor.html 27 | # for other strategies and supported options 28 | opts = [strategy: :one_for_one, name: LinkEquipment.Supervisor] 29 | Supervisor.start_link(children, opts) 30 | end 31 | 32 | # Tell Phoenix to update the endpoint configuration 33 | # whenever the application is updated. 34 | @impl true 35 | def config_change(changed, _new, removed) do 36 | LinkEquipmentWeb.Endpoint.config_change(changed, removed) 37 | :ok 38 | end 39 | 40 | defp skip_migrations? do 41 | # By default, sqlite migrations are run when using a release 42 | System.get_env("RELEASE_NAME") != nil 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/link_equipment_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipmentWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :link_equipment 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: "_link_equipment_key", 10 | signing_salt: "tsZhavDQ", 11 | same_site: "Lax" 12 | ] 13 | 14 | socket "/live", Phoenix.LiveView.Socket, 15 | websocket: [connect_info: [session: @session_options]], 16 | longpoll: [connect_info: [session: @session_options]] 17 | 18 | # Serve at "/" the static files from "priv/static" directory. 19 | # 20 | # You should set gzip to true if you are running phx.digest 21 | # when deploying your static files in production. 22 | plug Plug.Static, 23 | at: "/", 24 | from: :link_equipment, 25 | gzip: false, 26 | only: LinkEquipmentWeb.static_paths() 27 | 28 | # Code reloading can be explicitly enabled under the 29 | # :code_reloader configuration of your endpoint. 30 | if code_reloading? do 31 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 32 | plug Phoenix.LiveReloader 33 | plug Phoenix.CodeReloader 34 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :link_equipment 35 | end 36 | 37 | plug Phoenix.LiveDashboard.RequestLogger, 38 | param_key: "request_logger", 39 | cookie_key: "request_logger" 40 | 41 | plug Plug.RequestId 42 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 43 | 44 | plug Plug.Parsers, 45 | parsers: [:urlencoded, :multipart, :json], 46 | pass: ["*/*"], 47 | json_decoder: Phoenix.json_library() 48 | 49 | plug Plug.MethodOverride 50 | plug Plug.Head 51 | plug Plug.Session, @session_options 52 | plug LinkEquipmentWeb.Router 53 | end 54 | -------------------------------------------------------------------------------- /lib/link_equipment/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipment.Repo do 2 | use Ecto.Repo, 3 | otp_app: :link_equipment, 4 | adapter: Ecto.Adapters.SQLite3 5 | 6 | def meta do 7 | query("select * from sqlite_master") 8 | end 9 | 10 | @spec use_exclusive_connection_repo() :: pid() 11 | def use_exclusive_connection_repo do 12 | {:ok, repo} = 13 | start_link( 14 | name: nil, 15 | temp_store: :memory, 16 | pool_size: 1 17 | ) 18 | 19 | # This call is per process, i.e. scoped to the live view. 20 | put_dynamic_repo(repo) 21 | 22 | # do this somewhere in cleanup hook 23 | # Supervisor.stop(repo) 24 | repo 25 | end 26 | 27 | defmodule EctoURI do 28 | @moduledoc false 29 | use Ecto.Type 30 | 31 | def type, do: :map 32 | 33 | # Provide custom casting rules. 34 | # Cast strings into the URI struct to be used at runtime 35 | def cast(uri) when is_binary(uri) do 36 | {:ok, URI.parse(uri)} 37 | end 38 | 39 | # Accept casting of URI structs as well 40 | def cast(%URI{} = uri), do: {:ok, uri} 41 | 42 | # Everything else is a failure though 43 | def cast(_), do: :error 44 | 45 | # When loading data from the database, as long as it's a map, 46 | # we just put the data back into a URI struct to be stored in 47 | # the loaded schema struct. 48 | def load(data) when is_map(data) do 49 | data = 50 | for {key, val} <- data do 51 | {String.to_existing_atom(key), val} 52 | end 53 | 54 | {:ok, struct!(URI, data)} 55 | end 56 | 57 | # When dumping data to the database, we *expect* a URI struct 58 | # but any value could be inserted into the schema struct at runtime, 59 | # so we need to guard against them. 60 | def dump(%URI{} = uri), do: {:ok, Map.from_struct(uri)} 61 | def dump(_), do: :error 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipment.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 LinkEquipment.DataCase, async: true`, although 14 | this option is not recommended for other databases. 15 | """ 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | alias Ecto.Adapters.SQL.Sandbox 20 | 21 | using do 22 | quote do 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | import LinkEquipment.DataCase 27 | import LinkEquipment.Factory 28 | 29 | alias LinkEquipment.Repo 30 | end 31 | end 32 | 33 | setup tags do 34 | LinkEquipment.DataCase.setup_sandbox(tags) 35 | :ok 36 | end 37 | 38 | @doc """ 39 | Sets up the sandbox based on the test tags. 40 | """ 41 | def setup_sandbox(tags) do 42 | pid = Sandbox.start_owner!(LinkEquipment.Repo, shared: not tags[:async]) 43 | on_exit(fn -> Sandbox.stop_owner(pid) end) 44 | end 45 | 46 | @doc """ 47 | A helper that transforms changeset errors into a map of messages. 48 | 49 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 50 | assert "password is too short" in errors_on(changeset).password 51 | assert %{password: ["password is too short"]} = errors_on(changeset) 52 | 53 | """ 54 | def errors_on(changeset) do 55 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 56 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 57 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 58 | end) 59 | end) 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/link_equipment_web/components/raw_link_live_component.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipmentWeb.RawLinkLiveComponent do 2 | @moduledoc false 3 | use LinkEquipmentWeb, :live_component 4 | 5 | alias Ecto.Changeset 6 | alias LinkEquipment.RawLink 7 | alias LinkEquipment.StatusManager 8 | alias Phoenix.LiveView.AsyncResult 9 | 10 | def mount(socket) do 11 | ok(socket) 12 | end 13 | 14 | def update(assigns, socket) do 15 | socket 16 | |> assign(assigns) 17 | |> assign_status() 18 | |> ok() 19 | end 20 | 21 | defp assign_status(socket) do 22 | if is_nil(socket.assigns[:status]) do 23 | raw_link = socket.assigns.raw_link 24 | 25 | socket 26 | |> assign(:status, AsyncResult.loading()) 27 | |> start_async(:check_status, fn -> check_status(raw_link) end) 28 | else 29 | socket 30 | end 31 | end 32 | 33 | def handle_async(:check_status, {:ok, status}, socket) do 34 | LinkEquipment.Repo.update(Changeset.change(socket.assigns.raw_link, %{status: to_string(status)})) 35 | 36 | send(self(), {:raw_link_status_updated, nil}) 37 | 38 | socket 39 | |> assign(:status, AsyncResult.ok(socket.assigns.status, status)) 40 | |> noreply() 41 | end 42 | 43 | def handle_async(:check_status, {:exit, reason}, socket) do 44 | socket 45 | |> assign(:status, AsyncResult.failed(socket.assigns.status, {:exit, reason})) 46 | |> noreply() 47 | end 48 | 49 | def render(assigns) do 50 | assigns = assign(assigns, :data_attributes, data_attributes(assigns)) 51 | 52 | ~H""" 53 |
54 | <%= @raw_link.text %> 55 |
56 | """ 57 | end 58 | 59 | defp data_attributes(%{raw_link: raw_link, status: status}) do 60 | default_data = %{"data-order" => raw_link.order, "data-text" => raw_link.text} 61 | 62 | if status && status.ok? do 63 | Map.put(default_data, "data-status", status.result) 64 | else 65 | default_data 66 | end 67 | end 68 | 69 | defp check_status(raw_link) do 70 | if RawLink.http_or_https_url?(raw_link) do 71 | with {:ok, status} <- StatusManager.check_status(RawLink.unvalidated_url(raw_link)) do 72 | status 73 | end 74 | else 75 | :not_http_or_https 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # You can find the new timestamped tags here: https://hub.docker.com/r/gitpod/workspace-base/tags 2 | FROM gitpod/workspace-base:2024-10-23-18-11-15 3 | 4 | ENV ASDF_VERSION=v0.14.1 5 | ENV ASDF_DATA_DIR=${HOME}/.asdf 6 | 7 | # setup asdf-vm: https://asdf-vm.com/guide/getting-started.html 8 | RUN git clone --depth 1 https://github.com/asdf-vm/asdf.git ${ASDF_DATA_DIR} --branch ${ASDF_VERSION} \ 9 | && echo '. ${ASDF_DATA_DIR}/asdf.sh' >> ${HOME}/.bashrc 10 | 11 | # install and pin erlang: https://github.com/asdf-vm/asdf-erlang 12 | RUN bash -ic "asdf plugin add erlang https://github.com/asdf-vm/asdf-erlang.git && asdf plugin update erlang 826ad0e11d896caf1ffb59405949c2617cf60bbb" 13 | 14 | # install and pin elixir: https://github.com/asdf-vm/asdf-elixir 15 | RUN bash -ic "asdf plugin-add elixir https://github.com/asdf-vm/asdf-elixir.git && asdf plugin update elixir a4c42e10a7681afd4c87da144e9667865d5034c6" 16 | 17 | # install and pin rust: https://github.com/code-lever/asdf-rust 18 | RUN bash -ic "asdf plugin-add rust https://github.com/code-lever/asdf-rust.git && asdf plugin update rust 95acf4fe65df1de74fca502482b8f3ac5af73c05" 19 | 20 | # install and pin nodejs: https://github.com/asdf-vm/asdf-nodejs 21 | RUN bash -ic "asdf plugin add nodejs https://github.com/asdf-vm/asdf-nodejs.git && asdf plugin update nodejs c36e6f065d31437786e587de50f32c85b4972188" 22 | 23 | COPY .tool-versions ${HOME}/ 24 | 25 | RUN bash -ic "asdf install" 26 | 27 | ENV HEX_VERSION=v2.1.1 28 | 29 | # hex: https://github.com/hexpm/hex/releases 30 | # rebar (matching version depending on elixir? -> see: https://github.com/elixir-lang/elixir/blob/cc9e9b29a7b473010ed17f894e6a576983a9c294/lib/mix/lib/mix/tasks/local.rebar.ex#L124) 31 | RUN bash -ic "mix archive.install github hexpm/hex ref ${HEX_VERSION} --force && mix local.rebar --force" 32 | 33 | # setup persistent bash history 34 | RUN SNIPPET="export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \ 35 | && sudo mkdir /commandhistory \ 36 | && sudo touch /commandhistory/.bash_history \ 37 | && sudo chmod -R 777 /commandhistory \ 38 | && echo "${SNIPPET}" >> "${HOME}/.bashrc" 39 | 40 | # get inotify-tools for live reload from phoenix, see: https://hexdocs.pm/phoenix/installation.html#inotify-tools-for-linux-users 41 | RUN sudo install-packages inotify-tools -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_dispatch 5 | # push: 6 | # branches: [ "main" ] # adapt branch for project 7 | 8 | # Sets the ENV `MIX_ENV` to `test` for running tests 9 | env: 10 | MIX_ENV: test 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | test: 17 | runs-on: ubuntu-latest 18 | name: Test application 19 | steps: 20 | 21 | # Step: Check out the code. 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | 25 | # Step: Setup Elixir + Erlang image as the base. 26 | - name: Set up Elixir 27 | uses: erlef/setup-beam@v1 28 | with: 29 | version-file: .tool-versions 30 | version-type: strict 31 | 32 | # Step: Define how to cache deps. Restores existing cache if present. 33 | - name: Cache deps 34 | id: cache-deps 35 | uses: actions/cache@v4 36 | env: 37 | cache-name: cache-elixir-deps 38 | with: 39 | path: deps 40 | key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }} 41 | restore-keys: | 42 | ${{ runner.os }}-mix-${{ env.cache-name }}- 43 | 44 | # Step: Define how to cache the `_build` directory. After the first run, 45 | # this speeds up tests runs a lot. This includes not re-compiling our 46 | # project's downloaded deps every run. 47 | - name: Cache compiled build 48 | id: cache-build 49 | uses: actions/cache@v4 50 | env: 51 | cache-name: cache-compiled-build 52 | with: 53 | path: _build 54 | key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }} 55 | restore-keys: | 56 | ${{ runner.os }}-mix-${{ env.cache-name }}- 57 | ${{ runner.os }}-mix- 58 | 59 | # Step: Download project dependencies. If unchanged, uses 60 | # the cached version. 61 | - name: Install dependencies 62 | run: mix deps.get 63 | 64 | # Step: Compile the project treating any warnings as errors. 65 | - name: Compiles without warnings 66 | run: mix compile --warnings-as-errors 67 | 68 | # Step: Check that the checked in code has already been formatted. 69 | # This step fails if something was found unformatted. 70 | - name: Check Formatting 71 | run: mix format --check-formatted 72 | 73 | # Step: Execute the tests. 74 | - name: Run tests 75 | run: mix test -------------------------------------------------------------------------------- /lib/link_equipment/raw_link.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipment.RawLink do 2 | @moduledoc false 3 | 4 | use Ecto.Schema 5 | 6 | alias Util.Option 7 | 8 | @timestamps_opts [type: :utc_datetime] 9 | 10 | @type t :: %__MODULE__{ 11 | text: String.t(), 12 | element: Option.t(String.t()), 13 | attribute: Option.t(String.t()), 14 | order: integer(), 15 | base: Option.t(String.t()), 16 | status: Option.t(String.t()) 17 | } 18 | 19 | @derive { 20 | Flop.Schema, 21 | filterable: [:text, :status], sortable: [:text, :status], default_limit: 9999 22 | } 23 | 24 | @primary_key {:order, :integer, autogenerate: false} 25 | 26 | schema "raw_links" do 27 | field :text, :string 28 | field :element, :string 29 | field :attribute, :string 30 | field :base, :string 31 | field :status, :string 32 | end 33 | 34 | def html_representation(%__MODULE__{} = raw_link) do 35 | raw_link 36 | |> Map.from_struct() 37 | |> Map.values() 38 | |> Enum.map(fn 39 | nil -> "_" 40 | val -> val 41 | end) 42 | end 43 | 44 | def list_raw_links(params) do 45 | Flop.validate_and_run(__MODULE__, params, for: __MODULE__) 46 | end 47 | 48 | def create_temporary_table do 49 | sql = """ 50 | CREATE TEMPORARY TABLE raw_links( 51 | "text" TEXT, 52 | "element" TEXT, 53 | "attribute" TEXT, 54 | "order" INTEGER PRIMARY KEY, 55 | "base" TEXT, 56 | "status" TEXT 57 | ); 58 | """ 59 | 60 | LinkEquipment.Repo.query(sql) 61 | end 62 | 63 | @spec unvalidated_url(LinkEquipment.RawLink.t()) :: binary() 64 | def unvalidated_url(%__MODULE__{text: text, base: base}) do 65 | if String.starts_with?(text, "/") do 66 | base <> text 67 | else 68 | text 69 | end 70 | end 71 | 72 | @spec http_or_https_url?(LinkEquipment.RawLink.t()) :: boolean() 73 | def http_or_https_url?(%__MODULE__{} = raw_link) do 74 | match?({:ok, _}, validate_as_http_uri(raw_link)) 75 | end 76 | 77 | def validate_as_http_uri(%__MODULE__{} = raw_link) do 78 | with {:ok, uri} <- URI.new(unvalidated_url(raw_link)) do 79 | validate_as_http_uri(uri) 80 | end 81 | end 82 | 83 | def validate_as_http_uri(%URI{scheme: nil}), do: {:error, :scheme_missing} 84 | def validate_as_http_uri(%URI{scheme: ""}), do: {:error, :scheme_missing} 85 | def validate_as_http_uri(%URI{host: nil}), do: {:error, :host_missing} 86 | def validate_as_http_uri(%URI{host: ""}), do: {:error, :host_missing} 87 | def validate_as_http_uri(%URI{scheme: scheme} = uri) when scheme in ["http", "https"], do: {:ok, uri} 88 | def validate_as_http_uri(%URI{}), do: {:error, :not_http_or_https} 89 | end 90 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | config :link_equipment, LinkEquipment.Repo, 5 | database: Path.expand("../link_equipment_dev.db", __DIR__), 6 | pool_size: 5, 7 | stacktrace: true, 8 | show_sensitive_data_on_connection_error: true 9 | 10 | # For development, we disable any cache and enable 11 | # debugging and code reloading. 12 | # 13 | # The watchers configuration can be used to run external 14 | # watchers to your application. For example, we can use it 15 | # to bundle .js and .css sources. 16 | config :link_equipment, LinkEquipmentWeb.Endpoint, 17 | # Binding to loopback ipv4 address prevents access from other machines. 18 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 19 | http: [ip: {127, 0, 0, 1}, port: 4000], 20 | check_origin: false, 21 | code_reloader: true, 22 | debug_errors: true, 23 | secret_key_base: "DVi4DbBdTaNe2NYZOk9mkii0dNVjkWRD6HtRcoDR71/PENdWuYpZhxJIY8iiHF7q", 24 | watchers: [ 25 | esbuild: {Esbuild, :install_and_run, [:link_equipment, ~w(--sourcemap=inline --watch)]} 26 | ] 27 | 28 | # ## SSL Support 29 | # 30 | # In order to use HTTPS in development, a self-signed 31 | # certificate can be generated by running the following 32 | # Mix task: 33 | # 34 | # mix phx.gen.cert 35 | # 36 | # Run `mix help phx.gen.cert` for more information. 37 | # 38 | # The `http:` config above can be replaced with: 39 | # 40 | # https: [ 41 | # port: 4001, 42 | # cipher_suite: :strong, 43 | # keyfile: "priv/cert/selfsigned_key.pem", 44 | # certfile: "priv/cert/selfsigned.pem" 45 | # ], 46 | # 47 | # If desired, both `http:` and `https:` keys can be 48 | # configured to run both http and https servers on 49 | # different ports. 50 | 51 | # Watch static and templates for browser reloading. 52 | config :link_equipment, LinkEquipmentWeb.Endpoint, 53 | live_reload: [ 54 | web_console_logger: true, 55 | patterns: [ 56 | ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", 57 | ~r"lib/link_equipment_web/(controllers|live|components)/.*(ex|heex)$" 58 | ] 59 | ] 60 | 61 | # Enable dev routes for dashboard and mailbox 62 | config :link_equipment, dev_routes: true 63 | 64 | # Do not include metadata nor timestamps in development logs 65 | config :logger, :console, format: "[$level] $message\n" 66 | 67 | # Initialize plugs at runtime for faster development compilation 68 | config :phoenix, :plug_init_mode, :runtime 69 | 70 | # Set a higher stacktrace during development. Avoid configuring such 71 | # in production as building large stacktraces may be expensive. 72 | config :phoenix, :stacktrace_depth, 20 73 | 74 | config :phoenix_live_view, 75 | # Include HEEx debug annotations as HTML comments in rendered markup 76 | debug_heex_annotations: true, 77 | # Enable helpful, but potentially expensive runtime checks 78 | enable_expensive_runtime_checks: true 79 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | @import "../../deps/phexel/assets/elc.css"; 2 | @import "scale.css"; 3 | 4 | 5 | /* green: #348A36 :: rgba(52, 138, 54, 1) */ 6 | /* yellow: #E8DA1B :: rgba(232, 218, 27, 1) */ 7 | /* red: #D74F53 :: rgba(215, 79, 83, 1) */ 8 | 9 | :root { 10 | --link-green: 52, 138, 54; 11 | --link-yellow: 232, 218, 27; 12 | --link-red: 215, 79, 83; 13 | } 14 | 15 | html { 16 | scrollbar-gutter: stable 17 | } 18 | 19 | #living_source { 20 | overflow: auto; 21 | max-inline-size: 180ch; 22 | max-block-size: 90vh; 23 | } 24 | 25 | #raw_links_list { 26 | overflow: auto; 27 | max-block-size: 90vh; 28 | } 29 | 30 | pre, code { 31 | max-inline-size: none; 32 | inline-size: fit-content; 33 | } 34 | 35 | .link-status-red { 36 | background-color: rgba(var(--link-red), 0.4); 37 | outline: var(--s-3) solid rgba(var(--link-red), 0); 38 | } 39 | 40 | .link-status-red:focus { 41 | background-color: rgba(var(--link-red), 0.6); 42 | outline: 1px solid rgb(var(--link-red)); 43 | transition: outline var(--t-4) ease-out; 44 | } 45 | 46 | .link-status-yellow { 47 | background-color: rgba(var(--link-yellow), 0.2); 48 | outline: var(--s-3) solid rgba(var(--link-yellow), 0); 49 | } 50 | 51 | .link-status-yellow:focus { 52 | background-color: rgba(var(--link-yellow), 0.4); 53 | outline: 1px solid rgb(var(--link-yellow)); 54 | transition: outline var(--t-4) ease-in; 55 | } 56 | 57 | .link-status-green { 58 | background-color: rgba(var(--link-green), 0.1); 59 | outline: var(--s-3) solid rgba(var(--link-green), 0); 60 | } 61 | 62 | .link-status-green:focus { 63 | background-color: rgba(var(--link-green), 0.4); 64 | outline: 1px solid rgb(var(--link-green)); 65 | transition: outline var(--t-4) ease-in; 66 | } 67 | 68 | .line { 69 | display: block; 70 | max-inline-size: none; 71 | } 72 | 73 | /* only show lines with red status */ 74 | .line:not(:has(> .link-status-red)) { 75 | /* display: none; */ 76 | } 77 | 78 | /* show preceeding line of red status */ 79 | .line:has(+ .line > .link-status-red) { 80 | display: block; 81 | } 82 | 83 | /* show following line of red status */ 84 | .line:has(> .link-status-red) + .line { 85 | display: block; 86 | } 87 | 88 | /* separate contexts of red status unless they overlap, line after */ 89 | .line:has(> .link-status-red) + .line:not(:has(> .link-status-red, + .line > .link-status-red))::after { 90 | content: ''; 91 | display: block; 92 | max-inline-size: none; 93 | height: 1px; 94 | background-color: white; 95 | } 96 | 97 | /* separate contexts of red status unless they overlap, line before */ 98 | .line:not(:has(> .link-status-red)) + .line:has(+ .line > .link-status-red):not(:has(> .link-status-red))::before { 99 | content: ''; 100 | display: block; 101 | max-inline-size: none; 102 | height: 1px; 103 | background-color: white; 104 | } -------------------------------------------------------------------------------- /priv/static/images/logo.svg: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipment.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :link_equipment, 7 | version: "0.1.0", 8 | elixir: "~> 1.17", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | start_permanent: Mix.env() == :prod, 11 | test_paths: ["lib"], 12 | aliases: aliases(), 13 | deps: deps() 14 | ] 15 | end 16 | 17 | # Configuration for the OTP application. 18 | # 19 | # Type `mix help compile.app` for more information. 20 | def application do 21 | [ 22 | mod: {LinkEquipment.Application, []}, 23 | extra_applications: [:logger, :runtime_tools] 24 | ] 25 | end 26 | 27 | # Specifies which paths to compile per environment. 28 | defp elixirc_paths(:test), do: ["lib", "test/support"] 29 | defp elixirc_paths(_), do: ["lib"] 30 | 31 | # Specifies your project dependencies. 32 | # 33 | # Type `mix help deps` for examples and options. 34 | defp deps do 35 | [ 36 | {:phoenix, "~> 1.7.14"}, 37 | {:phoenix_ecto, "~> 4.5"}, 38 | {:ecto_sql, "~> 3.10"}, 39 | {:ecto_sqlite3, ">= 0.0.0"}, 40 | {:phoenix_html, "~> 4.1"}, 41 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 42 | # TODO bump on release to {:phoenix_live_view, "~> 1.0.0"}, 43 | {:phoenix_live_view, "1.0.0-rc.6", override: true}, 44 | {:floki, ">= 0.30.0", only: :test}, 45 | {:phoenix_live_dashboard, "~> 0.8.3"}, 46 | {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, 47 | {:telemetry_metrics, "~> 1.0"}, 48 | {:telemetry_poller, "~> 1.0"}, 49 | {:jason, "~> 1.2"}, 50 | {:dns_cluster, "~> 0.1.1"}, 51 | {:bandit, "~> 1.5"}, 52 | {:rustler, "~> 0.34"}, 53 | # query based tables 54 | {:flop, "~> 0.26.1"}, 55 | {:flop_phoenix, "~> 0.23.1"}, 56 | # persistent job processing 57 | {:oban, "~> 2.17"}, 58 | # cache, yo 59 | {:cachex, "~> 4.0"}, 60 | # generate test data 61 | {:faker, "~> 0.18", only: :test}, 62 | # http client 63 | {:req, "~> 0.5.0"}, 64 | # UI layouting 65 | {:phexel, git: "https://github.com/SilvanCodes/phexel"}, 66 | # code quality tooling 67 | {:styler, "~> 1.1.2", only: [:dev, :test], runtime: false}, 68 | {:credo, "~> 1.7", only: [:dev, :test], runtime: false} 69 | ] 70 | end 71 | 72 | # Aliases are shortcuts or tasks specific to the current project. 73 | # For example, to install project dependencies and perform other setup tasks, run: 74 | # 75 | # $ mix setup 76 | # 77 | # See the documentation for `Mix` for more info on aliases. 78 | defp aliases do 79 | [ 80 | setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"], 81 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 82 | "ecto.reset": ["ecto.drop", "ecto.setup"], 83 | test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], 84 | "assets.setup": ["esbuild.install --if-missing"], 85 | "assets.build": ["esbuild link_equipment"], 86 | "assets.deploy": [ 87 | "esbuild link_equipment --minify", 88 | "phx.digest" 89 | ] 90 | ] 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/link_equipment_web/live/home_live.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipmentWeb.HomeLive do 2 | @moduledoc false 3 | use LinkEquipmentWeb, :live_view 4 | 5 | import Util.Validation, only: [validate_as_remote_uri: 1] 6 | 7 | alias LinkEquipment.ScanManager 8 | alias LinkEquipmentWeb.LinkLiveComponent 9 | 10 | def mount(_params, _session, socket) do 11 | ok(socket) 12 | end 13 | 14 | def handle_params(params, _uri, socket) do 15 | socket = assign_params(socket, params) 16 | 17 | socket = assign_results(socket) 18 | 19 | noreply(socket) 20 | end 21 | 22 | def assign_results(socket) do 23 | with {:ok, url_input} <- get_param_result(socket, :url_input), 24 | {:ok, uri} <- URI.new(url_input), 25 | {:ok, uri} <- validate_as_remote_uri(uri) do 26 | assign_async(socket, :results, fn -> scan_url(URI.to_string(uri)) end) 27 | else 28 | {:error, error} -> 29 | socket |> assign(:results, nil) |> add_param_error(:url_input, error) 30 | end 31 | end 32 | 33 | def handle_event("validate", params, socket) do 34 | params = 35 | params 36 | |> Map.take(["url_input"]) 37 | |> merge_params(socket) 38 | 39 | socket 40 | |> push_patch(to: configured_path(params), replace: true) 41 | |> noreply() 42 | end 43 | 44 | def handle_event("check_all", _params, socket) do 45 | Enum.each( 46 | socket.assigns.results.result, 47 | &send_update(LinkLiveComponent, id: :base64.encode(URI.to_string(&1.url)), check: true) 48 | ) 49 | 50 | noreply(socket) 51 | end 52 | 53 | def render(assigns) do 54 | ~H""" 55 | <.stack> 56 | <.center> 57 | <.form for={@params} phx-change="validate" phx-submit="scan"> 58 | <.cluster> 59 | <.input type="text" field={@params[:url_input]} label="URL:" phx-debounce="200" /> 60 | 61 | 62 | 63 | <.center> 64 | <.async :let={results} :if={@results} assign={@results}> 65 | <:loading> 66 |

Scanning...

67 | 68 | <.stack> 69 | <.cluster> 70 |

Last Results (<%= Enum.count(results) %>)

71 | <.button phx-click="check_all">Check all 72 | 73 | 74 | <.stack tag="ul"> 75 |
  • 76 | <.live_component 77 | module={LinkLiveComponent} 78 | id={:base64.encode(URI.to_string(result.url))} 79 | link={result} 80 | /> 81 |
  • 82 | 83 | 84 | <:failed :let={_failure}> 85 |

    There was an error scanning the URL :(

    86 | 87 | 88 | 89 | 90 | """ 91 | end 92 | 93 | defp scan_url(url) do 94 | with {:ok, results} <- ScanManager.check_scan(url) do 95 | {:ok, %{results: results}} 96 | end 97 | end 98 | 99 | defp configured_path(params), do: ~p"/v1?#{params}" 100 | end 101 | -------------------------------------------------------------------------------- /lib/link_equipment_web/components/link_live_component.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipmentWeb.LinkLiveComponent do 2 | @moduledoc false 3 | use LinkEquipmentWeb, :live_component 4 | 5 | alias LinkEquipment.StatusManager 6 | 7 | def mount(socket) do 8 | socket 9 | |> assign(status: nil) 10 | |> ok() 11 | end 12 | 13 | def update(assigns, socket) do 14 | socket = 15 | if assigns[:check] && socket.assigns.link.url.scheme in ["http", "https"] do 16 | url = URI.to_string(socket.assigns.link.url) 17 | 18 | assign_async(socket, :status, fn -> check_status(url) end) 19 | else 20 | assign(socket, assigns) 21 | end 22 | 23 | ok(socket) 24 | end 25 | 26 | def handle_event("scan", params, socket) do 27 | socket 28 | |> push_patch(to: ~p"/scan?#{Map.take(params, ["url_input"])}") 29 | |> noreply() 30 | end 31 | 32 | def handle_event("check", _params, socket) do 33 | send_update(__MODULE__, id: socket.assigns.id, check: true) 34 | 35 | noreply(socket) 36 | end 37 | 38 | def render(assigns) do 39 | ~H""" 40 |
    41 | <.box style={status_border_color(@status)}> 42 | <.cluster> 43 |

    <%= @link.url %>

    44 | <.cluster :if={@link.url.scheme in ["http", "https"]}> 45 | <.button 46 | phx-click={JS.push("scan", value: %{"url_input" => URI.to_string(@link.url)})} 47 | phx-target={@myself} 48 | > 49 | Scan 50 | 51 | 52 | <.link href={URI.to_string(@link.url)} target="_blank"> 53 | <.button>Open 54 | 55 | 56 | <.link href={source_url(@link)}> 57 | <.button>Source 58 | 59 | 60 | <.status status={@status} target={@myself} /> 61 | 62 | 63 | 64 |
    65 | """ 66 | end 67 | 68 | defp source_url(link) do 69 | source = URI.to_string(link.source_document_url) 70 | url = URI.to_string(link.url) 71 | 72 | text_fragment = "#:~:text=\"#{url}\"" 73 | 74 | ~p"/source?#{%{source: source}}" <> text_fragment 75 | end 76 | 77 | defp status(assigns) do 78 | ~H""" 79 | <%= cond do %> 80 | <% @status == nil -> %> 81 | <.button phx-click="check" phx-target={@target}> 82 | Check 83 | 84 | <% @status.loading -> %> 85 | Checking... 86 | <% @status.ok? -> %> 87 | <%= @status.result %> 88 | <% end %> 89 | """ 90 | end 91 | 92 | defp check_status(url) do 93 | with {:ok, status} <- StatusManager.check_status(url) do 94 | {:ok, %{status: status}} 95 | end 96 | end 97 | 98 | defp status_border_color(status) do 99 | cond do 100 | status == nil -> "border-color: black" 101 | status.loading -> "border-color: gray" 102 | status.result in 200..299 -> "border-color: green" 103 | status.result in 300..399 -> "border-color: yellow" 104 | status.result in 400..599 -> "border-color: red" 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/link_equipment_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipmentWeb.Telemetry do 2 | @moduledoc false 3 | use Supervisor 4 | 5 | import Telemetry.Metrics 6 | 7 | def start_link(arg) do 8 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 9 | end 10 | 11 | @impl true 12 | def init(_arg) do 13 | children = [ 14 | # Telemetry poller will execute the given period measurements 15 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 16 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 17 | # Add reporters as children of your supervision tree. 18 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 19 | ] 20 | 21 | Supervisor.init(children, strategy: :one_for_one) 22 | end 23 | 24 | def metrics do 25 | [ 26 | # Phoenix Metrics 27 | summary("phoenix.endpoint.start.system_time", 28 | unit: {:native, :millisecond} 29 | ), 30 | summary("phoenix.endpoint.stop.duration", 31 | unit: {:native, :millisecond} 32 | ), 33 | summary("phoenix.router_dispatch.start.system_time", 34 | tags: [:route], 35 | unit: {:native, :millisecond} 36 | ), 37 | summary("phoenix.router_dispatch.exception.duration", 38 | tags: [:route], 39 | unit: {:native, :millisecond} 40 | ), 41 | summary("phoenix.router_dispatch.stop.duration", 42 | tags: [:route], 43 | unit: {:native, :millisecond} 44 | ), 45 | summary("phoenix.socket_connected.duration", 46 | unit: {:native, :millisecond} 47 | ), 48 | summary("phoenix.channel_joined.duration", 49 | unit: {:native, :millisecond} 50 | ), 51 | summary("phoenix.channel_handled_in.duration", 52 | tags: [:event], 53 | unit: {:native, :millisecond} 54 | ), 55 | 56 | # Database Metrics 57 | summary("link_equipment.repo.query.total_time", 58 | unit: {:native, :millisecond}, 59 | description: "The sum of the other measurements" 60 | ), 61 | summary("link_equipment.repo.query.decode_time", 62 | unit: {:native, :millisecond}, 63 | description: "The time spent decoding the data received from the database" 64 | ), 65 | summary("link_equipment.repo.query.query_time", 66 | unit: {:native, :millisecond}, 67 | description: "The time spent executing the query" 68 | ), 69 | summary("link_equipment.repo.query.queue_time", 70 | unit: {:native, :millisecond}, 71 | description: "The time spent waiting for a database connection" 72 | ), 73 | summary("link_equipment.repo.query.idle_time", 74 | unit: {:native, :millisecond}, 75 | description: "The time the connection spent waiting before being checked out for the query" 76 | ), 77 | 78 | # VM Metrics 79 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 80 | summary("vm.total_run_queue_lengths.total"), 81 | summary("vm.total_run_queue_lengths.cpu"), 82 | summary("vm.total_run_queue_lengths.io") 83 | ] 84 | end 85 | 86 | defp periodic_measurements do 87 | [ 88 | # A module, function and arguments to be invoked periodically. 89 | # This function must call :telemetry.execute/3 and a metric must be added above. 90 | # {LinkEquipmentWeb, :count_users, []} 91 | ] 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/link_equipment_web.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipmentWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, components, channels, and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use LinkEquipmentWeb, :controller 9 | use LinkEquipmentWeb, :html 10 | 11 | The definitions below will be executed for every controller, 12 | component, 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 additional modules and import 17 | those modules here. 18 | """ 19 | 20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) 21 | 22 | def router do 23 | quote do 24 | use Phoenix.Router, helpers: false 25 | 26 | import Phoenix.Controller 27 | import Phoenix.LiveView.Router 28 | 29 | # Import common connection and controller functions to use in pipelines 30 | import Plug.Conn 31 | end 32 | end 33 | 34 | def channel do 35 | quote do 36 | use Phoenix.Channel 37 | end 38 | end 39 | 40 | def controller do 41 | quote do 42 | use Phoenix.Controller, 43 | formats: [:html, :json], 44 | layouts: [html: LinkEquipmentWeb.Layouts] 45 | 46 | import Plug.Conn 47 | 48 | unquote(verified_routes()) 49 | end 50 | end 51 | 52 | def live_view do 53 | quote do 54 | use Phoenix.LiveView, 55 | layout: {LinkEquipmentWeb.Layouts, :app} 56 | 57 | unquote(html_helpers()) 58 | end 59 | end 60 | 61 | def live_component do 62 | quote do 63 | use Phoenix.LiveComponent 64 | 65 | unquote(html_helpers()) 66 | end 67 | end 68 | 69 | def html do 70 | quote do 71 | use Phoenix.Component 72 | 73 | # Import convenience functions from controllers 74 | import Phoenix.Controller, 75 | only: [get_csrf_token: 0, view_module: 1, view_template: 1] 76 | 77 | # Include general helpers for rendering HTML 78 | unquote(html_helpers()) 79 | end 80 | end 81 | 82 | defp html_helpers do 83 | quote do 84 | # application components 85 | import LinkEquipmentWeb.Components 86 | 87 | # Core UI components and translation 88 | import LinkEquipmentWeb.CoreComponents 89 | 90 | # Layouting components 91 | import Phexel 92 | 93 | # HTML escaping functionality 94 | import Phoenix.HTML 95 | 96 | # helpful stuff for sockets 97 | import Util.Phoenix 98 | 99 | # helpful stuff for ok/error 100 | import Util.Result, only: [ok: 1, error: 1] 101 | 102 | # Shortcut for generating JS commands 103 | alias Phoenix.LiveView.JS 104 | 105 | # helpful stuff for any()/nil 106 | alias Util.Option 107 | 108 | # helpful stuff for ok/error 109 | alias Util.Result 110 | 111 | # Routes generation with the ~p sigil 112 | unquote(verified_routes()) 113 | end 114 | end 115 | 116 | def verified_routes do 117 | quote do 118 | use Phoenix.VerifiedRoutes, 119 | endpoint: LinkEquipmentWeb.Endpoint, 120 | router: LinkEquipmentWeb.Router, 121 | statics: LinkEquipmentWeb.static_paths() 122 | end 123 | end 124 | 125 | @doc """ 126 | When used, dispatch to the appropriate controller/live_view/etc. 127 | """ 128 | defmacro __using__(which) when is_atom(which) do 129 | apply(__MODULE__, which, []) 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config/runtime.exs is executed for all environments, including 4 | # during releases. It is executed after compilation and before the 5 | # system starts, so it is typically used to load production configuration 6 | # and secrets from environment variables or elsewhere. Do not define 7 | # any compile-time configuration in here, as it won't be applied. 8 | # The block below contains prod specific runtime configuration. 9 | 10 | # ## Using releases 11 | # 12 | # If you use `mix release`, you need to explicitly enable the server 13 | # by passing the PHX_SERVER=true when you start it: 14 | # 15 | # PHX_SERVER=true bin/link_equipment start 16 | # 17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` 18 | # script that automatically sets the env var above. 19 | if System.get_env("PHX_SERVER") do 20 | config :link_equipment, LinkEquipmentWeb.Endpoint, server: true 21 | end 22 | 23 | if config_env() == :prod do 24 | database_path = 25 | System.get_env("DATABASE_PATH") || 26 | raise """ 27 | environment variable DATABASE_PATH is missing. 28 | For example: /etc/link_equipment/link_equipment.db 29 | """ 30 | 31 | # The secret key base is used to sign/encrypt cookies and other secrets. 32 | # A default value is used in config/dev.exs and config/test.exs but you 33 | # want to use a different value for prod and you most likely don't want 34 | # to check this value into version control, so we use an environment 35 | # variable instead. 36 | secret_key_base = 37 | System.get_env("SECRET_KEY_BASE") || 38 | raise """ 39 | environment variable SECRET_KEY_BASE is missing. 40 | You can generate one by calling: mix phx.gen.secret 41 | """ 42 | 43 | host = System.get_env("PHX_HOST") || "example.com" 44 | port = String.to_integer(System.get_env("PORT") || "4000") 45 | 46 | config :link_equipment, LinkEquipment.Repo, 47 | database: database_path, 48 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5") 49 | 50 | config :link_equipment, LinkEquipmentWeb.Endpoint, 51 | url: [host: host, port: 443, scheme: "https"], 52 | http: [ 53 | # Enable IPv6 and bind on all interfaces. 54 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 55 | # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0 56 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 57 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 58 | port: port 59 | ], 60 | secret_key_base: secret_key_base 61 | 62 | config :link_equipment, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") 63 | 64 | # ## SSL Support 65 | # 66 | # To get SSL working, you will need to add the `https` key 67 | # to your endpoint configuration: 68 | # 69 | # config :link_equipment, LinkEquipmentWeb.Endpoint, 70 | # https: [ 71 | # ..., 72 | # port: 443, 73 | # cipher_suite: :strong, 74 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 75 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 76 | # ] 77 | # 78 | # The `cipher_suite` is set to `:strong` to support only the 79 | # latest and more secure SSL ciphers. This means old browsers 80 | # and clients may not be supported. You can set it to 81 | # `:compatible` for wider support. 82 | # 83 | # `:keyfile` and `:certfile` expect an absolute path to the key 84 | # and cert in disk or a relative path inside priv, for example 85 | # "priv/ssl/server.key". For all supported SSL configuration 86 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 87 | # 88 | # We also recommend setting `force_ssl` in your config/prod.exs, 89 | # ensuring no data is ever sent via http, always redirecting to https: 90 | # 91 | # config :link_equipment, LinkEquipmentWeb.Endpoint, 92 | # force_ssl: [hsts: true] 93 | # 94 | # Check `Plug.SSL` for all available options in `force_ssl`. 95 | end 96 | -------------------------------------------------------------------------------- /lib/util.ex: -------------------------------------------------------------------------------- 1 | defmodule Util do 2 | @moduledoc false 3 | alias Phoenix.Component 4 | alias Phoenix.LiveView.Socket 5 | 6 | defmodule Phoenix do 7 | @moduledoc false 8 | import Component, only: [assign: 3, to_form: 1, update: 3] 9 | 10 | alias Util.Result 11 | 12 | @type params :: %{String.t() => any()} 13 | 14 | @spec noreply(Socket.t()) :: {:noreply, Socket.t()} 15 | def noreply(socket), do: {:noreply, socket} 16 | 17 | @spec reply(Socket.t(), map()) :: {:reply, map(), Socket.t()} 18 | def reply(socket, map), do: {:reply, map, socket} 19 | 20 | @spec assign_params(Socket.t(), params()) :: Socket.t() 21 | def assign_params(socket, params) do 22 | assign(socket, :params, to_form(params)) 23 | end 24 | 25 | @spec get_params(Socket.t()) :: params() 26 | def get_params(socket) do 27 | socket.assigns[:params].params 28 | end 29 | 30 | @spec get_param_result(Socket.t(), atom()) :: Result.t(any()) 31 | def get_param_result(socket, key) do 32 | case get_param(socket, key) do 33 | nil -> 34 | {:error, :unset} 35 | 36 | value -> 37 | {:ok, value} 38 | end 39 | end 40 | 41 | @spec get_param(Socket.t(), atom()) :: any() 42 | def get_param(socket, key) do 43 | socket.assigns[:params][key].value 44 | end 45 | 46 | @spec merge_params(params(), Socket.t()) :: params() 47 | def merge_params(params, socket) do 48 | Map.merge(get_params(socket), params) 49 | end 50 | 51 | @spec add_param_error(Socket.t(), atom(), any()) :: Socket.t() 52 | def add_param_error(socket, key, error) do 53 | update(socket, :params, fn state -> 54 | Map.update( 55 | state, 56 | :errors, 57 | [{key, {error, []}}], 58 | &Keyword.put(&1, key, {error, []}) 59 | ) 60 | end) 61 | end 62 | end 63 | 64 | defmodule Option do 65 | @moduledoc false 66 | 67 | @type _some(type) :: [type] 68 | @type _none :: [] 69 | 70 | @type t(type) :: _some(type) | _none() 71 | 72 | defguard is_some(option) when is_list(option) and length(option) == 1 73 | defguard is_none(option) when is_list(option) and option == [] 74 | 75 | @spec wrap(any()) :: Option.t(any()) 76 | def wrap(value), do: List.wrap(value) 77 | 78 | @spec unwrap(Option.t(any())) :: any() 79 | def unwrap([value]), do: value 80 | def unwrap([]), do: nil 81 | 82 | @spec map(Option.t(any()), function()) :: Option.t(any()) 83 | def map(option, fun), do: Enum.map(option, fun) 84 | end 85 | 86 | defmodule Result do 87 | @moduledoc false 88 | import Util.Option 89 | 90 | @type success(type) :: {:ok, type} 91 | @type failure :: {:error, any()} 92 | 93 | @type t(type) :: success(type) | failure() 94 | 95 | @spec ok(any()) :: success(any()) 96 | def ok(value), do: {:ok, value} 97 | 98 | @spec error(any()) :: failure() 99 | def error(value), do: {:error, value} 100 | 101 | @spec from_option(Option.t(any())) :: t(any()) 102 | def from_option(option) when is_some(option), do: option |> unwrap() |> ok() 103 | def from_option(option) when is_none(option), do: option |> unwrap() |> error() 104 | end 105 | 106 | defmodule Validation do 107 | @moduledoc false 108 | 109 | @spec validate_as_remote_uri(URI.t()) :: Result.t(URI.t()) 110 | def validate_as_remote_uri(uri) 111 | 112 | def validate_as_remote_uri(%URI{scheme: nil}), do: {:error, :scheme_missing} 113 | def validate_as_remote_uri(%URI{scheme: ""}), do: {:error, :scheme_missing} 114 | 115 | def validate_as_remote_uri(%URI{scheme: scheme}) when scheme not in ["http", "https"], 116 | do: {:error, :not_http_or_https} 117 | 118 | def validate_as_remote_uri(%URI{host: nil}), do: {:error, :host_missing} 119 | def validate_as_remote_uri(%URI{host: ""}), do: {:error, :host_missing} 120 | 121 | def validate_as_remote_uri(%URI{host: host} = uri) do 122 | if String.contains?(host, ".") do 123 | {:ok, uri} 124 | else 125 | {:error, :missing_apex_domain} 126 | end 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /native/linkequipment_lychee/src/lib.rs: -------------------------------------------------------------------------------- 1 | use lychee_lib::{ 2 | extract::Extractor, Collector, FileType, Input, InputContent, InputSource, Request, Result, 3 | }; 4 | use reqwest::Url; 5 | use rustler::NifStruct; 6 | use tokio_stream::StreamExt; 7 | 8 | #[derive(Debug, NifStruct)] 9 | #[module = "LinkEquipment.Link"] 10 | struct Link { 11 | url: Uri, 12 | source_document_url: Uri, 13 | html_element: Option, 14 | element_attribute: Option, 15 | } 16 | 17 | impl From for Link { 18 | fn from(value: Request) -> Self { 19 | let source = match value.source { 20 | InputSource::RemoteUrl(url) => *url, 21 | _ => panic!("only remote urls supported"), 22 | }; 23 | 24 | let url = match Url::parse(value.uri.as_str()) { 25 | Ok(url) => url, 26 | Err(url::ParseError::RelativeUrlWithoutBase) => Url::options() 27 | .base_url(Some(&source)) 28 | .parse(value.uri.as_str()) 29 | .unwrap(), 30 | 31 | Err(_) => panic!("cant parse url"), 32 | }; 33 | 34 | Self { 35 | url: url.into(), 36 | source_document_url: source.into(), 37 | html_element: value.element, 38 | element_attribute: value.attribute, 39 | } 40 | } 41 | } 42 | 43 | #[derive(Debug, NifStruct)] 44 | #[module = "URI"] 45 | struct Uri { 46 | authority: Option, 47 | fragment: Option, 48 | host: Option, 49 | path: Option, 50 | port: Option, 51 | query: Option, 52 | scheme: Option, 53 | userinfo: Option, 54 | } 55 | 56 | impl From for Uri { 57 | fn from(value: reqwest::Url) -> Self { 58 | Self { 59 | authority: value.domain().map(str::to_string), 60 | fragment: None, 61 | host: value.domain().map(str::to_string), 62 | path: Some(value.path().to_string()), 63 | port: value.port(), 64 | query: value.query().map(str::to_string), 65 | scheme: Some(value.scheme().to_string()), 66 | userinfo: None, 67 | } 68 | } 69 | } 70 | 71 | async fn do_collect_links(url: Url) -> Result> { 72 | // Collect all links from the following inputs 73 | let inputs = vec![Input { 74 | source: InputSource::RemoteUrl(Box::new(url)), 75 | file_type_hint: None, 76 | excluded_paths: None, 77 | }]; 78 | 79 | Collector::new(None) // base 80 | .skip_missing_inputs(false) // don't skip missing inputs? (default=false) 81 | .use_html5ever(false) // use html5ever for parsing? (default=false) 82 | .collect_links(inputs) // base url or directory 83 | .collect::>>() 84 | .await 85 | } 86 | 87 | #[rustler::nif] 88 | fn collect_links(url: String) -> std::result::Result, ()> { 89 | let url = Url::parse(&url).unwrap(); 90 | let rt = tokio::runtime::Runtime::new().unwrap(); 91 | let future = do_collect_links(url); 92 | let result = rt.block_on(future); 93 | 94 | match result { 95 | Result::Ok(links) => Ok(links.into_iter().map(Link::from).collect::>()), 96 | Result::Err(_) => Err(()), 97 | } 98 | } 99 | 100 | #[derive(Debug, NifStruct)] 101 | #[module = "LinkEquipment.RawLink"] 102 | struct RawLink { 103 | text: String, 104 | element: Option, 105 | attribute: Option, 106 | order: usize, 107 | } 108 | 109 | #[rustler::nif] 110 | fn extract_links(source: String) -> Vec { 111 | let input_content = InputContent::from_string(source.as_str(), FileType::Html); 112 | 113 | Extractor::new(false, true) 114 | .extract(&input_content) 115 | .into_iter() 116 | .enumerate() 117 | .map(|(index, raw_uri)| RawLink { 118 | text: raw_uri.text, 119 | element: raw_uri.element, 120 | attribute: raw_uri.attribute, 121 | order: index, 122 | }) 123 | .collect::>() 124 | } 125 | 126 | rustler::init!("Elixir.LinkEquipment.Lychee"); 127 | -------------------------------------------------------------------------------- /lib/link_equipment/factory.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipment.Factory do 2 | @moduledoc """ 3 | Default entrypoint for constructing entities for tests. 4 | 5 | Expects the folllowing conventions: 6 | - Entities have an ".Repo" module 7 | - Entities have an ".Factory" module 8 | """ 9 | 10 | @type entity :: struct() 11 | @type fields :: map() 12 | @type opts :: keyword() 13 | 14 | @callback build() :: entity() 15 | @callback build(fields()) :: entity() 16 | @callback build(fields(), opts()) :: entity() 17 | 18 | @callback insert() :: {:ok, entity()} | {:error, any()} 19 | @callback insert(fields()) :: {:ok, entity()} | {:error, any()} 20 | @callback insert(fields(), opts()) :: {:ok, entity()} | {:error, any()} 21 | 22 | @doc """ 23 | Use to construct an entity in memory, dispatching to the ".Factory" build/0 function. 24 | """ 25 | @spec build(module()) :: entity() 26 | def build(module), do: String.to_existing_atom("#{module}.Factory").build() 27 | 28 | @doc """ 29 | Use to construct an entity in memory, dispatching to the ".Factory" build/1 function. 30 | """ 31 | @spec build(module(), fields()) :: entity() 32 | def build(module, fields), do: String.to_existing_atom("#{module}.Factory").build(fields) 33 | 34 | @doc """ 35 | Use to construct an entity in memory, dispatching to the ".Factory" build/2 function. 36 | """ 37 | @spec build(module(), fields(), opts()) :: entity() 38 | def build(module, fields, opts), do: String.to_existing_atom("#{module}.Factory").build(fields, opts) 39 | 40 | @doc """ 41 | Use to construct and persist an entity, dispatching to the ".Factory" insert/0 function. 42 | """ 43 | @spec insert(module()) :: {:ok, entity()} | {:error, any()} 44 | def insert(module), do: String.to_existing_atom("#{module}.Factory").insert() 45 | 46 | @doc """ 47 | Use to construct and persist an entity, dispatching to the ".Factory" insert/1 function. 48 | """ 49 | @spec insert(module(), fields()) :: {:ok, entity()} | {:error, any()} 50 | def insert(module, fields), do: String.to_existing_atom("#{module}.Factory").insert(fields) 51 | 52 | @doc """ 53 | Use to construct and persist an entity, dispatching to the ".Factory" insert/2 function. 54 | """ 55 | @spec insert(module(), fields(), opts()) :: {:ok, entity()} | {:error, any()} 56 | def insert(module, fields, opts), do: String.to_existing_atom("#{module}.Factory").insert(fields, opts) 57 | 58 | defmacro __using__(_opts \\ []) do 59 | behaviour = __MODULE__ 60 | 61 | quote bind_quoted: [behaviour: behaviour] do 62 | @behaviour behaviour 63 | 64 | alias LinkEquipment.Factory 65 | 66 | @before_compile behaviour 67 | 68 | @entity __MODULE__ |> Atom.to_string() |> String.trim_trailing(".Factory") |> String.to_existing_atom() 69 | 70 | @entity_repo String.to_existing_atom("#{@entity}.Repo") 71 | 72 | def build, do: raise("build/0 not implemented in #{__MODULE__}") 73 | def build(fields), do: raise("build/1 not implemented in #{__MODULE__}") 74 | def build(fields, opts), do: raise("build/2 not implemented in #{__MODULE__}") 75 | 76 | def insert, do: @entity_repo.insert(build()) 77 | def insert(fields), do: @entity_repo.insert(build(fields)) 78 | def insert(fields, opts), do: @entity_repo.insert(build(fields, opts)) 79 | 80 | defoverridable behaviour 81 | end 82 | end 83 | 84 | defmacro __before_compile__(env) do 85 | Module.put_attribute(env.module, :ecto_internal_fields, [:id, :inserted_at, :updated_at]) 86 | 87 | quote location: :keep do 88 | defoverridable(build: 2) 89 | 90 | def build(fields, opts) do 91 | case Map.keys(fields) -- @entity.__schema__(:fields) -- @ecto_internal_fields do 92 | [] -> 93 | super(fields, opts) 94 | 95 | disallowed_fields -> 96 | raise ArgumentError, 97 | "#{inspect(__MODULE__)} does not accept fields #{inspect(disallowed_fields)} as they are not defined in the schema or internally managed." 98 | end 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/link_equipment_web/components/living_source_live_component.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipmentWeb.LivingSourceLiveComponent do 2 | @moduledoc false 3 | use LinkEquipmentWeb, :live_component 4 | 5 | alias LinkEquipment.RawLink 6 | alias LinkEquipment.SourceManager 7 | alias LinkEquipment.StatusManager 8 | alias Phoenix.LiveView.AsyncResult 9 | 10 | attr :source_url, :string, required: true 11 | 12 | def mount(socket) do 13 | ok(socket) 14 | end 15 | 16 | @spec update(maybe_improper_list() | map(), any()) :: any() 17 | def update(assigns, socket) do 18 | socket 19 | |> assign(assigns) 20 | |> assign_source() 21 | |> ok() 22 | end 23 | 24 | defp assign_source(socket) do 25 | source_url_result = 26 | socket.assigns[:source_url] 27 | |> Option.wrap() 28 | |> Result.from_option() 29 | 30 | with {:ok, url} <- source_url_result, 31 | {:ok, uri} <- URI.new(url), 32 | {:ok, uri} <- RawLink.validate_as_http_uri(uri) do 33 | socket 34 | |> assign(:source, AsyncResult.loading()) 35 | |> start_async(:get_source, fn -> get_source(URI.to_string(uri)) end) 36 | else 37 | {:error, error} -> 38 | assign(socket, :source, AsyncResult.failed(socket.assigns[:source] || AsyncResult.loading(), error)) 39 | end 40 | end 41 | 42 | def handle_async(:get_source, {:ok, {:error, error}}, socket) do 43 | socket 44 | |> assign(:source, AsyncResult.failed(socket.assigns.source, {:error, error})) 45 | |> noreply() 46 | end 47 | 48 | def handle_async(:get_source, {:ok, {source, url}}, socket) do 49 | base = 50 | url 51 | |> URI.parse() 52 | |> Map.put(:path, nil) 53 | |> Map.put(:query, nil) 54 | |> Map.put(:fragment, nil) 55 | |> URI.to_string() 56 | 57 | raw_links = 58 | source 59 | |> LinkEquipment.Lychee.extract_links() 60 | |> Enum.map(&Map.put(&1, :base, base)) 61 | 62 | encoded_links = 63 | raw_links 64 | |> Enum.map(&RawLink.html_representation/1) 65 | |> Enum.map_join("||", &Enum.join(&1, "|")) 66 | |> :base64.encode() 67 | 68 | socket 69 | |> assign(:source, AsyncResult.ok(socket.assigns.source, source)) 70 | |> assign(:base, base) 71 | |> assign(:raw_links, raw_links) 72 | |> assign(:encoded_links, encoded_links) 73 | |> noreply() 74 | end 75 | 76 | def handle_async(:get_source, {:exit, reason}, socket) do 77 | socket 78 | |> assign(:source, AsyncResult.failed(socket.assigns.source, {:exit, reason})) 79 | |> noreply() 80 | end 81 | 82 | def handle_async("check-status-" <> _order, {:ok, {:ok, status_results}}, socket) do 83 | socket 84 | |> push_event("update-link-status", status_results) 85 | |> noreply() 86 | end 87 | 88 | def handle_async("check-status-" <> _order, {:ok, {:error, status_results}}, socket) do 89 | socket 90 | |> push_event("update-link-status", status_results) 91 | |> noreply() 92 | end 93 | 94 | def handle_async("check-status-" <> _order, unhandeled, socket) do 95 | dbg(unhandeled) 96 | 97 | noreply(socket) 98 | end 99 | 100 | defp check_status(%RawLink{text: text, order: order} = raw_link) do 101 | if RawLink.http_or_https_url?(raw_link) do 102 | case StatusManager.check_status(RawLink.unvalidated_url(raw_link)) do 103 | {:ok, status} -> 104 | {:ok, %{status: status, text: text, order: order}} 105 | 106 | {:error, error} -> 107 | {:error, %{status: error, text: text, order: order}} 108 | end 109 | else 110 | {:error, %{status: :not_http_or_https, text: text, order: order}} 111 | end 112 | end 113 | 114 | def check_status_all(socket) do 115 | Enum.reduce(socket.assigns.raw_links, socket, fn raw_link, socket -> 116 | start_async(socket, "check-status-#{raw_link.order}", fn -> check_status(raw_link) end) 117 | end) 118 | end 119 | 120 | def handle_event("check-status", _params, socket) do 121 | socket 122 | |> check_status_all() 123 | |> noreply() 124 | end 125 | 126 | def render(assigns) do 127 | ~H""" 128 |
    129 | <.async :let={source} :if={@source} assign={@source}> 130 | <:loading> 131 |

    Getting source...

    132 | 133 |
    139 |
    140 |             
    141 |     <%= source %>
    142 |             
    143 |           
    144 |
    145 | <:failed :let={_failure}> 146 |

    There was an error getting the source. :(

    147 | 148 | 149 |
    150 | """ 151 | end 152 | 153 | defp get_source(url) do 154 | with {:ok, source} <- SourceManager.check_source(url) do 155 | {source, url} 156 | end 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /lib/link_equipment_web/live/source_live.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipmentWeb.SourceLive do 2 | @moduledoc false 3 | use LinkEquipmentWeb, :live_view 4 | 5 | import Util.Validation, only: [validate_as_remote_uri: 1] 6 | 7 | alias LinkEquipment.RawLink 8 | alias LinkEquipment.SourceManager 9 | alias LinkEquipmentWeb.RawLinkLiveComponent 10 | alias Phoenix.LiveView.AsyncResult 11 | 12 | def mount(_params, _session, socket) do 13 | socket 14 | |> setup_raw_links_temporary_table() 15 | |> ok() 16 | end 17 | 18 | def handle_params(params, _uri, socket) do 19 | socket 20 | |> assign_params(params) 21 | |> assign_source() 22 | |> assign_raw_links() 23 | |> noreply() 24 | end 25 | 26 | defp assign_source(socket) do 27 | with {:ok, source_url} <- get_param_result(socket, :source_url), 28 | {:ok, uri} <- URI.new(source_url), 29 | {:ok, uri} <- validate_as_remote_uri(uri) do 30 | if socket.assigns[:source_url] == source_url do 31 | socket 32 | else 33 | socket 34 | |> assign(:source_url, source_url) 35 | |> assign(:source, AsyncResult.loading()) 36 | |> start_async(:get_source, fn -> get_source(URI.to_string(uri)) end) 37 | end 38 | else 39 | {:error, error} -> 40 | socket 41 | |> add_param_error(:source_url, error) 42 | |> assign(:source, nil) 43 | end 44 | end 45 | 46 | def handle_info({:raw_link_status_updated, nil}, socket) do 47 | socket 48 | |> assign_raw_links() 49 | |> noreply() 50 | end 51 | 52 | defp assign_raw_links(socket) do 53 | case RawLink.list_raw_links(get_params(socket)) do 54 | {:ok, {raw_links, meta}} -> 55 | assign(socket, %{raw_links: raw_links, meta: meta}) 56 | 57 | {:error, _meta} -> 58 | assign(socket, %{raw_links: nil, meta: nil}) 59 | end 60 | end 61 | 62 | def handle_async(:get_source, {:ok, {:error, error}}, socket) do 63 | socket 64 | |> assign(:source, AsyncResult.failed(socket.assigns.source, {:error, error})) 65 | |> noreply() 66 | end 67 | 68 | def handle_async(:get_source, {:ok, {source, url}}, socket) do 69 | base = 70 | url 71 | |> URI.parse() 72 | |> Map.put(:path, nil) 73 | |> Map.put(:query, nil) 74 | |> Map.put(:fragment, nil) 75 | |> URI.to_string() 76 | 77 | # could be done async 78 | LinkEquipment.Repo.delete_all(RawLink) 79 | raw_links = source |> LinkEquipment.Lychee.extract_links() |> Enum.map(&Map.put(&1, :base, base)) 80 | LinkEquipment.Repo.insert_all(RawLink, Enum.map(raw_links, &Map.from_struct/1), on_conflict: :replace_all) 81 | 82 | socket 83 | |> assign(:source, AsyncResult.ok(socket.assigns.source, source)) 84 | |> assign_raw_links() 85 | |> noreply() 86 | end 87 | 88 | def handle_async(:get_source, {:exit, reason}, socket) do 89 | socket 90 | |> assign(:source, AsyncResult.failed(socket.assigns.source, {:exit, reason})) 91 | |> noreply() 92 | end 93 | 94 | def handle_event("scan", params, socket) do 95 | params = 96 | params 97 | |> Map.take(["source_url"]) 98 | |> merge_params(socket) 99 | 100 | socket 101 | |> push_patch(to: configured_path(params), replace: true) 102 | |> noreply() 103 | end 104 | 105 | defp configured_path(params), do: ~p"/v2?#{params}" 106 | 107 | defp get_source(url) do 108 | with {:ok, source} <- SourceManager.check_source(url) do 109 | {source, url} 110 | end 111 | end 112 | 113 | def render(assigns) do 114 | ~H""" 115 | <.stack> 116 | <.center> 117 | <.form for={@params} phx-change="scan"> 118 | <.input type="text" field={@params[:source_url]} label="URL:" /> 119 | 120 | 121 | <.sidebar> 122 | <.raw_links :if={@raw_links} raw_links={@raw_links} meta={@meta} path={table_path(assigns)} /> 123 | <.living_source source={@source} /> 124 | 125 | 126 | """ 127 | end 128 | 129 | defp table_path(assigns) do 130 | # prevent Flop from stacking its parameters in the url 131 | assigns.params.params 132 | |> Map.drop(["order_by", "order_directions"]) 133 | |> configured_path() 134 | end 135 | 136 | defp raw_links(assigns) do 137 | ~H""" 138 |

    Result Count: <%= @meta.total_count %>

    139 | 140 | <:col :let={raw_link} label="Text" field={:text}> 141 | <.live_component 142 | module={RawLinkLiveComponent} 143 | raw_link={raw_link} 144 | id={"#{:base64.encode(raw_link.text)}-#{raw_link.order}"} 145 | /> 146 | 147 | <:col :let={raw_link} label="Status" field={:status}><%= raw_link.status %> 148 | 149 | """ 150 | end 151 | 152 | defp setup_raw_links_temporary_table(socket) do 153 | if is_nil(socket.assigns[:repo]) do 154 | repo = LinkEquipment.Repo.use_exclusive_connection_repo() 155 | LinkEquipment.RawLink.create_temporary_table() 156 | assign(socket, :repo, repo) 157 | else 158 | socket 159 | end 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /assets/vendor/topbar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license MIT 3 | * topbar 2.0.0, 2023-02-04 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 | currentProgress, 39 | showing, 40 | progressTimerId = null, 41 | fadeTimerId = null, 42 | delayTimerId = null, 43 | addEvent = function (elem, type, handler) { 44 | if (elem.addEventListener) elem.addEventListener(type, handler, false); 45 | else if (elem.attachEvent) elem.attachEvent("on" + type, handler); 46 | else elem["on" + type] = handler; 47 | }, 48 | options = { 49 | autoRun: true, 50 | barThickness: 3, 51 | barColors: { 52 | 0: "rgba(26, 188, 156, .9)", 53 | ".25": "rgba(52, 152, 219, .9)", 54 | ".50": "rgba(241, 196, 15, .9)", 55 | ".75": "rgba(230, 126, 34, .9)", 56 | "1.0": "rgba(211, 84, 0, .9)", 57 | }, 58 | shadowBlur: 10, 59 | shadowColor: "rgba(0, 0, 0, .6)", 60 | className: null, 61 | }, 62 | repaint = function () { 63 | canvas.width = window.innerWidth; 64 | canvas.height = options.barThickness * 5; // need space for shadow 65 | 66 | var ctx = canvas.getContext("2d"); 67 | ctx.shadowBlur = options.shadowBlur; 68 | ctx.shadowColor = options.shadowColor; 69 | 70 | var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); 71 | for (var stop in options.barColors) 72 | lineGradient.addColorStop(stop, options.barColors[stop]); 73 | ctx.lineWidth = options.barThickness; 74 | ctx.beginPath(); 75 | ctx.moveTo(0, options.barThickness / 2); 76 | ctx.lineTo( 77 | Math.ceil(currentProgress * canvas.width), 78 | options.barThickness / 2 79 | ); 80 | ctx.strokeStyle = lineGradient; 81 | ctx.stroke(); 82 | }, 83 | createCanvas = function () { 84 | canvas = document.createElement("canvas"); 85 | var style = canvas.style; 86 | style.position = "fixed"; 87 | style.top = style.left = style.right = style.margin = style.padding = 0; 88 | style.zIndex = 100001; 89 | style.display = "none"; 90 | if (options.className) canvas.classList.add(options.className); 91 | document.body.appendChild(canvas); 92 | addEvent(window, "resize", repaint); 93 | }, 94 | topbar = { 95 | config: function (opts) { 96 | for (var key in opts) 97 | if (options.hasOwnProperty(key)) options[key] = opts[key]; 98 | }, 99 | show: function (delay) { 100 | if (showing) return; 101 | if (delay) { 102 | if (delayTimerId) return; 103 | delayTimerId = setTimeout(() => topbar.show(), delay); 104 | } else { 105 | showing = true; 106 | if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); 107 | if (!canvas) createCanvas(); 108 | canvas.style.opacity = 1; 109 | canvas.style.display = "block"; 110 | topbar.progress(0); 111 | if (options.autoRun) { 112 | (function loop() { 113 | progressTimerId = window.requestAnimationFrame(loop); 114 | topbar.progress( 115 | "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) 116 | ); 117 | })(); 118 | } 119 | } 120 | }, 121 | progress: function (to) { 122 | if (typeof to === "undefined") return currentProgress; 123 | if (typeof to === "string") { 124 | to = 125 | (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 126 | ? currentProgress 127 | : 0) + parseFloat(to); 128 | } 129 | currentProgress = to > 1 ? 1 : to; 130 | repaint(); 131 | return currentProgress; 132 | }, 133 | hide: function () { 134 | clearTimeout(delayTimerId); 135 | delayTimerId = null; 136 | if (!showing) return; 137 | showing = false; 138 | if (progressTimerId != null) { 139 | window.cancelAnimationFrame(progressTimerId); 140 | progressTimerId = null; 141 | } 142 | (function loop() { 143 | if (topbar.progress("+.1") >= 1) { 144 | canvas.style.opacity -= 0.05; 145 | if (canvas.style.opacity <= 0.05) { 146 | canvas.style.display = "none"; 147 | fadeTimerId = null; 148 | return; 149 | } 150 | } 151 | fadeTimerId = window.requestAnimationFrame(loop); 152 | })(); 153 | }, 154 | }; 155 | 156 | if (typeof module === "object" && typeof module.exports === "object") { 157 | module.exports = topbar; 158 | } else if (typeof define === "function" && define.amd) { 159 | define(function () { 160 | return topbar; 161 | }); 162 | } else { 163 | this.topbar = topbar; 164 | } 165 | }.call(this, window, document)); 166 | -------------------------------------------------------------------------------- /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 | import "../css/app.css" 19 | 20 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 21 | import "phoenix_html" 22 | // Establish Phoenix Socket and LiveView configuration. 23 | import { Socket } from "phoenix" 24 | import { LiveSocket } from "phoenix_live_view" 25 | import topbar from "../vendor/topbar" 26 | 27 | import { createHighlighter } from 'shiki' 28 | 29 | 30 | import { 31 | transformerRemoveLineBreak, 32 | } from '@shikijs/transformers' 33 | 34 | // `createHighlighter` is async, it initializes the internal and 35 | // loads the themes and languages specified. 36 | (async () => { 37 | const highlighter = await createHighlighter({ 38 | themes: ['nord'], 39 | langs: ['html'], 40 | }); 41 | 42 | window.highlighter = highlighter; 43 | })(); 44 | 45 | 46 | const statusElementId = rawLink => `${btoa(rawLink.dataset.text)}-${rawLink.dataset.order}-status` 47 | 48 | const collectRawLinks = () => Array.from(document.querySelectorAll('[phx-hook="LivingRawLink"]')).sort((a, b) => a.dataset.order - b.dataset.order); 49 | 50 | const parseLinks = () => atob(document.getElementById('living_source').dataset.links).split("||").map(v => v.split("|")) 51 | 52 | const linkStatusTransformer = { 53 | name: 'link-highlighter', 54 | preprocess(code, options) { 55 | const rawLinks = collectRawLinks(); 56 | options.decorations ||= [] 57 | 58 | let offset = 0; 59 | 60 | for (const rawLink of rawLinks) { 61 | const text = rawLink.dataset.text; 62 | 63 | const index = code.indexOf(text, offset); 64 | 65 | if (index !== -1) { 66 | const end = index + text.length; 67 | 68 | options.decorations.push({ 69 | start: index, 70 | end: end, 71 | properties: { 72 | id: statusElementId(rawLink), 73 | tabindex: rawLink.dataset.order, 74 | // "phx-click": "foo" 75 | }, 76 | }); 77 | 78 | offset = (end + 1) 79 | } else { 80 | console.error("failed to find text:", text) 81 | } 82 | } 83 | } 84 | } 85 | 86 | const linkStatusTransformerV3 = { 87 | name: 'link-highlighter', 88 | preprocess(code, options) { 89 | const links = parseLinks(); 90 | options.decorations ||= [] 91 | 92 | let offset = 0; 93 | 94 | for (const [element, attribute, text, base, order] of links) { 95 | const rawLink = { 96 | dataset: { 97 | text, 98 | element, 99 | attribute, 100 | order, 101 | base 102 | } 103 | } 104 | 105 | const start = code.indexOf(text, offset); 106 | 107 | if (start !== -1) { 108 | const end = start + text.length; 109 | 110 | options.decorations.push({ 111 | start, 112 | end, 113 | properties: { 114 | id: statusElementId(rawLink), 115 | tabindex: order 116 | }, 117 | }); 118 | 119 | offset = (end + 1) 120 | } else { 121 | console.error("failed to find text:", text) 122 | } 123 | } 124 | } 125 | } 126 | 127 | const statusData = status => { 128 | switch (status) { 129 | case "200": 130 | return ["link-status-green", "200 OK"]; 131 | case "403": 132 | return ["link-status-yellow", "403 Not Allowed"]; 133 | case "404": 134 | return ["link-status-red", "404 Not Found"]; 135 | case "not_http_or_https": 136 | return ["gray", "No HTTP(S) URL"]; 137 | default: 138 | console.error("unmatched status:", status) 139 | ["blue", "status not resolved"] 140 | } 141 | } 142 | 143 | let Hooks = {} 144 | 145 | Hooks.LivingSource = { 146 | mounted() { 147 | this.updated(); 148 | }, 149 | updated() { 150 | const ivingSourceElement = document.getElementById('living_source') 151 | const source = ivingSourceElement.textContent; 152 | const target = ivingSourceElement.dataset.target 153 | 154 | 155 | let living_source = window.highlighter.codeToHtml(source, { 156 | lang: 'html', 157 | theme: 'nord', 158 | transformers: [ 159 | // linkStatusTransformer 160 | linkStatusTransformerV3, 161 | transformerRemoveLineBreak() 162 | ] 163 | }) 164 | 165 | this.el.innerHTML = living_source; 166 | // parsing by shiki seems to produce some empty lines, fix this 167 | Array.from(document.querySelectorAll(".line:empty")).forEach(l => l.remove()) 168 | this.pushEventTo(target, "check-status", {}) 169 | } 170 | } 171 | 172 | Hooks.LivingRawLink = { 173 | mounted() { 174 | const rawLink = this.el; 175 | 176 | rawLink.addEventListener("click", () => { 177 | document.getElementById(statusElementId(rawLink)).focus({ focusVisible: true }); 178 | }) 179 | }, 180 | updated() { 181 | const rawLink = this.el; 182 | const status = rawLink.dataset.status; 183 | const statusElement = document.getElementById(statusElementId(rawLink)); 184 | 185 | if (statusElement) { 186 | const [cssClass, title] = statusData(status); 187 | 188 | // should eventually remove "old" status classes 189 | statusElement.classList.add(cssClass); 190 | statusElement.title = title 191 | } 192 | } 193 | } 194 | 195 | window.addEventListener("phx:update-link-status", (e) => { 196 | const { status, text, order } = e.detail; 197 | 198 | const rawLink = { 199 | dataset: { 200 | text, 201 | order, 202 | } 203 | } 204 | 205 | const statusElement = document.getElementById(statusElementId(rawLink)); 206 | 207 | if (statusElement) { 208 | const [cssClass, title] = statusData(`${status}`); 209 | 210 | statusElement.classList.remove("link-status-red", "link-status-yellow", "link-status-green") 211 | statusElement.classList.add(cssClass); 212 | statusElement.title = title 213 | } 214 | }) 215 | 216 | window.addEventListener("phx:live_reload:attached", ({ detail: reloader }) => { 217 | // Enable server log streaming to client. 218 | // Disable with reloader.disableServerLogs() 219 | reloader.enableServerLogs() 220 | window.liveReloader = reloader 221 | }) 222 | 223 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 224 | 225 | let liveSocket = new LiveSocket("/live", Socket, { 226 | longPollFallbackMs: 2500, 227 | params: { _csrf_token: csrfToken }, 228 | hooks: Hooks 229 | }) 230 | 231 | // Show progress bar on live navigation and form submits 232 | topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }) 233 | window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) 234 | window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) 235 | 236 | // connect if there are any LiveViews on the page 237 | liveSocket.connect() 238 | 239 | // expose liveSocket on window for web console debug logs and latency simulation: 240 | // >> liveSocket.enableDebug() 241 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 242 | // >> liveSocket.disableLatencySim() 243 | window.liveSocket = liveSocket 244 | 245 | 246 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bandit": {:hex, :bandit, "1.5.7", "6856b1e1df4f2b0cb3df1377eab7891bec2da6a7fd69dc78594ad3e152363a50", [:mix], [{:hpax, "~> 1.0.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "f2dd92ae87d2cbea2fa9aa1652db157b6cba6c405cb44d4f6dd87abba41371cd"}, 3 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 4 | "cachex": {:hex, :cachex, "4.0.2", "120f9c27b0a453c7cb3319d9dc6c61c050a480e5299fc1f8bded1e2e334992ab", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:ex_hash_ring, "~> 6.0", [hex: :ex_hash_ring, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "4f4890122bddd979f6c217d5e300d0c0d3eb858a976cbe1f65a94e6322bc5825"}, 5 | "castore": {:hex, :castore, "1.0.9", "5cc77474afadf02c7c017823f460a17daa7908e991b0cc917febc90e466a375c", [:mix], [], "hexpm", "5ea956504f1ba6f2b4eb707061d8e17870de2bee95fb59d512872c2ef06925e7"}, 6 | "cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"}, 7 | "credo": {:hex, :credo, "1.7.8", "9722ba1681e973025908d542ec3d95db5f9c549251ba5b028e251ad8c24ab8c5", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cb9e87cc64f152f3ed1c6e325e7b894dea8f5ef2e41123bd864e3cd5ceb44968"}, 8 | "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, 9 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 10 | "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, 11 | "ecto": {:hex, :ecto, "3.12.4", "267c94d9f2969e6acc4dd5e3e3af5b05cdae89a4d549925f3008b2b7eb0b93c3", [:mix], [{:decimal, "~> 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", "ef04e4101688a67d061e1b10d7bc1fbf00d1d13c17eef08b71d070ff9188f747"}, 12 | "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 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", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, 13 | "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.17.2", "200226e057f76c40be55fbac77771eb1a233260ab8ec7283f5da6d9402bde8de", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "a3838919c5a34c268c28cafab87b910bcda354a9a4e778658da46c149bb2c1da"}, 14 | "elixir_make": {:hex, :elixir_make, "0.8.4", "4960a03ce79081dee8fe119d80ad372c4e7badb84c493cc75983f9d3bc8bde0f", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "6e7f1d619b5f61dfabd0a20aa268e575572b542ac31723293a4c1a567d5ef040"}, 15 | "esbuild": {:hex, :esbuild, "0.8.2", "5f379dfa383ef482b738e7771daf238b2d1cfb0222bef9d3b20d4c8f06c7a7ac", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "558a8a08ed78eb820efbfda1de196569d8bfa9b51e8371a1934fbb31345feda7"}, 16 | "eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"}, 17 | "ex_hash_ring": {:hex, :ex_hash_ring, "6.0.4", "bef9d2d796afbbe25ab5b5a7ed746e06b99c76604f558113c273466d52fa6d6b", [:mix], [], "hexpm", "89adabf31f7d3dfaa36802ce598ce918e9b5b33bae8909ac1a4d052e1e567d18"}, 18 | "exqlite": {:hex, :exqlite, "0.26.0", "8c6118cdd36482b0081d1ca7c3f8db514eb4c0765f853936ef096757165cedf3", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "e4a5e0c8309e3a3981f6803aa7073bc5777559fe5bc367543c30c7b95f0aed28"}, 19 | "faker": {:hex, :faker, "0.18.0", "943e479319a22ea4e8e39e8e076b81c02827d9302f3d32726c5bf82f430e6e14", [:mix], [], "hexpm", "bfbdd83958d78e2788e99ec9317c4816e651ad05e24cfd1196ce5db5b3e81797"}, 20 | "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, 21 | "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, 22 | "floki": {:hex, :floki, "0.36.3", "1102f93b16a55bc5383b85ae3ec470f82dee056eaeff9195e8afdf0ef2a43c30", [:mix], [], "hexpm", "fe0158bff509e407735f6d40b3ee0d7deb47f3f3ee7c6c182ad28599f9f6b27a"}, 23 | "flop": {:hex, :flop, "0.26.1", "f0e9c6895cf876f667e9ff1c0398e53df87087fcd82d9cea8989332b9c0e1358", [:mix], [{:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}], "hexpm", "5fcab8a1ee78111159fc4752dc9823862343b6d6bd527ff947ec1e1c27018485"}, 24 | "flop_phoenix": {:hex, :flop_phoenix, "0.23.1", "beb23c1703f9e1292687b26e8b292fbe6547e3b2aa11fbe9416c1828d3b7bfb4", [:mix], [{:flop, ">= 0.23.0 and < 0.27.0", [hex: :flop, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6.0 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0.0-rc.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "e7d55a2eb869960ae7abcc760e0626d655e2a7852e433932005d7e94dd2b5d92"}, 25 | "hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"}, 26 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 27 | "jumper": {:hex, :jumper, "1.0.2", "68cdcd84472a00ac596b4e6459a41b3062d4427cbd4f1e8c8793c5b54f1406a7", [:mix], [], "hexpm", "9b7782409021e01ab3c08270e26f36eb62976a38c1aa64b2eaf6348422f165e1"}, 28 | "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 29 | "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"}, 30 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 31 | "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 32 | "oban": {:hex, :oban, "2.18.3", "1608c04f8856c108555c379f2f56bc0759149d35fa9d3b825cb8a6769f8ae926", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "36ca6ca84ef6518f9c2c759ea88efd438a3c81d667ba23b02b062a0aa785475e"}, 33 | "phexel": {:git, "https://github.com/SilvanCodes/phexel", "29ef57b9ffc6f7c1c66e4509912cd52aecf13660", []}, 34 | "phoenix": {:hex, :phoenix, "1.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"}, 35 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.2", "3b83b24ab5a2eb071a20372f740d7118767c272db386831b2e77638c4dcc606d", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "3f94d025f59de86be00f5f8c5dd7b5965a3298458d21ab1c328488be3b5fcd59"}, 36 | "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, 37 | "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, 38 | "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.4", "4508e481f791ce62ec6a096e13b061387158cbeefacca68c6c1928e1305e23ed", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "2984aae96994fbc5c61795a73b8fb58153b41ff934019cfb522343d2d3817d59"}, 39 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, 40 | "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.0-rc.6", "47d2669995ea326e5c71f5c1bc9177109cebf211385c638faa7b5862a401e516", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e56e4f1642a0b20edc2488cab30e5439595e0d8b5b259f76ef98b1c4e2e5b527"}, 41 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 42 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 43 | "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, 44 | "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, 45 | "req": {:hex, :req, "0.5.6", "8fe1eead4a085510fe3d51ad854ca8f20a622aae46e97b302f499dfb84f726ac", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cfaa8e720945d46654853de39d368f40362c2641c4b2153c886418914b372185"}, 46 | "rustler": {:hex, :rustler, "0.35.0", "1e2e379e1150fab9982454973c74ac9899bd0377b3882166ee04127ea613b2d9", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "a176bea1bb6711474f9dfad282066f2b7392e246459bf4e29dfff6d828779fdf"}, 47 | "sleeplocks": {:hex, :sleeplocks, "1.1.3", "96a86460cc33b435c7310dbd27ec82ca2c1f24ae38e34f8edde97f756503441a", [:rebar3], [], "hexpm", "d3b3958552e6eb16f463921e70ae7c767519ef8f5be46d7696cc1ed649421321"}, 48 | "styler": {:hex, :styler, "1.1.2", "d5b14cd4f8f7cc45624d9485cd0edb277ec92583b118409cfcbcb7c78efa5f4b", [:mix], [], "hexpm", "b46edab1f129d0c839d426755e172cf92118e5fac877456d074156b335f1f80b"}, 49 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 50 | "telemetry_metrics": {:hex, :telemetry_metrics, "1.0.0", "29f5f84991ca98b8eb02fc208b2e6de7c95f8bb2294ef244a176675adc7775df", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f23713b3847286a534e005126d4c959ebcca68ae9582118ce436b521d1d47d5d"}, 51 | "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"}, 52 | "thousand_island": {:hex, :thousand_island, "1.3.5", "6022b6338f1635b3d32406ff98d68b843ba73b3aa95cfc27154223244f3a6ca5", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2be6954916fdfe4756af3239fb6b6d75d0b8063b5df03ba76fd8a4c87849e180"}, 53 | "toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"}, 54 | "unsafe": {:hex, :unsafe, "1.0.2", "23c6be12f6c1605364801f4b47007c0c159497d0446ad378b5cf05f1855c0581", [:mix], [], "hexpm", "b485231683c3ab01a9cd44cb4a79f152c6f3bb87358439c6f68791b85c2df675"}, 55 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 56 | "websock_adapter": {:hex, :websock_adapter, "0.5.7", "65fa74042530064ef0570b75b43f5c49bb8b235d6515671b3d250022cb8a1f9e", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d0f478ee64deddfec64b800673fd6e0c8888b079d9f3444dd96d2a98383bdbd1"}, 57 | } 58 | -------------------------------------------------------------------------------- /assets/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "assets", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "devDependencies": { 8 | "@shikijs/transformers": "^1.22.2", 9 | "shiki": "^1.22.1" 10 | } 11 | }, 12 | "node_modules/@shikijs/core": { 13 | "version": "1.22.2", 14 | "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.22.2.tgz", 15 | "integrity": "sha512-bvIQcd8BEeR1yFvOYv6HDiyta2FFVePbzeowf5pPS1avczrPK+cjmaxxh0nx5QzbON7+Sv0sQfQVciO7bN72sg==", 16 | "dev": true, 17 | "license": "MIT", 18 | "dependencies": { 19 | "@shikijs/engine-javascript": "1.22.2", 20 | "@shikijs/engine-oniguruma": "1.22.2", 21 | "@shikijs/types": "1.22.2", 22 | "@shikijs/vscode-textmate": "^9.3.0", 23 | "@types/hast": "^3.0.4", 24 | "hast-util-to-html": "^9.0.3" 25 | } 26 | }, 27 | "node_modules/@shikijs/engine-javascript": { 28 | "version": "1.22.2", 29 | "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-1.22.2.tgz", 30 | "integrity": "sha512-iOvql09ql6m+3d1vtvP8fLCVCK7BQD1pJFmHIECsujB0V32BJ0Ab6hxk1ewVSMFA58FI0pR2Had9BKZdyQrxTw==", 31 | "dev": true, 32 | "license": "MIT", 33 | "dependencies": { 34 | "@shikijs/types": "1.22.2", 35 | "@shikijs/vscode-textmate": "^9.3.0", 36 | "oniguruma-to-js": "0.4.3" 37 | } 38 | }, 39 | "node_modules/@shikijs/engine-oniguruma": { 40 | "version": "1.22.2", 41 | "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.22.2.tgz", 42 | "integrity": "sha512-GIZPAGzQOy56mGvWMoZRPggn0dTlBf1gutV5TdceLCZlFNqWmuc7u+CzD0Gd9vQUTgLbrt0KLzz6FNprqYAxlA==", 43 | "dev": true, 44 | "license": "MIT", 45 | "dependencies": { 46 | "@shikijs/types": "1.22.2", 47 | "@shikijs/vscode-textmate": "^9.3.0" 48 | } 49 | }, 50 | "node_modules/@shikijs/transformers": { 51 | "version": "1.22.2", 52 | "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-1.22.2.tgz", 53 | "integrity": "sha512-8f78OiBa6pZDoZ53lYTmuvpFPlWtevn23bzG+azpPVvZg7ITax57o/K3TC91eYL3OMJOO0onPbgnQyZjRos8XQ==", 54 | "dev": true, 55 | "license": "MIT", 56 | "dependencies": { 57 | "shiki": "1.22.2" 58 | } 59 | }, 60 | "node_modules/@shikijs/types": { 61 | "version": "1.22.2", 62 | "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-1.22.2.tgz", 63 | "integrity": "sha512-NCWDa6LGZqTuzjsGfXOBWfjS/fDIbDdmVDug+7ykVe1IKT4c1gakrvlfFYp5NhAXH/lyqLM8wsAPo5wNy73Feg==", 64 | "dev": true, 65 | "license": "MIT", 66 | "dependencies": { 67 | "@shikijs/vscode-textmate": "^9.3.0", 68 | "@types/hast": "^3.0.4" 69 | } 70 | }, 71 | "node_modules/@shikijs/vscode-textmate": { 72 | "version": "9.3.0", 73 | "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-9.3.0.tgz", 74 | "integrity": "sha512-jn7/7ky30idSkd/O5yDBfAnVt+JJpepofP/POZ1iMOxK59cOfqIgg/Dj0eFsjOTMw+4ycJN0uhZH/Eb0bs/EUA==", 75 | "dev": true, 76 | "license": "MIT" 77 | }, 78 | "node_modules/@types/hast": { 79 | "version": "3.0.4", 80 | "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", 81 | "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", 82 | "dev": true, 83 | "license": "MIT", 84 | "dependencies": { 85 | "@types/unist": "*" 86 | } 87 | }, 88 | "node_modules/@types/mdast": { 89 | "version": "4.0.4", 90 | "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", 91 | "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", 92 | "dev": true, 93 | "license": "MIT", 94 | "dependencies": { 95 | "@types/unist": "*" 96 | } 97 | }, 98 | "node_modules/@types/unist": { 99 | "version": "3.0.3", 100 | "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", 101 | "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", 102 | "dev": true, 103 | "license": "MIT" 104 | }, 105 | "node_modules/@ungap/structured-clone": { 106 | "version": "1.2.0", 107 | "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", 108 | "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", 109 | "dev": true, 110 | "license": "ISC" 111 | }, 112 | "node_modules/ccount": { 113 | "version": "2.0.1", 114 | "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", 115 | "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", 116 | "dev": true, 117 | "license": "MIT", 118 | "funding": { 119 | "type": "github", 120 | "url": "https://github.com/sponsors/wooorm" 121 | } 122 | }, 123 | "node_modules/character-entities-html4": { 124 | "version": "2.1.0", 125 | "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", 126 | "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", 127 | "dev": true, 128 | "license": "MIT", 129 | "funding": { 130 | "type": "github", 131 | "url": "https://github.com/sponsors/wooorm" 132 | } 133 | }, 134 | "node_modules/character-entities-legacy": { 135 | "version": "3.0.0", 136 | "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", 137 | "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", 138 | "dev": true, 139 | "license": "MIT", 140 | "funding": { 141 | "type": "github", 142 | "url": "https://github.com/sponsors/wooorm" 143 | } 144 | }, 145 | "node_modules/comma-separated-tokens": { 146 | "version": "2.0.3", 147 | "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", 148 | "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", 149 | "dev": true, 150 | "license": "MIT", 151 | "funding": { 152 | "type": "github", 153 | "url": "https://github.com/sponsors/wooorm" 154 | } 155 | }, 156 | "node_modules/dequal": { 157 | "version": "2.0.3", 158 | "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", 159 | "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", 160 | "dev": true, 161 | "license": "MIT", 162 | "engines": { 163 | "node": ">=6" 164 | } 165 | }, 166 | "node_modules/devlop": { 167 | "version": "1.1.0", 168 | "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", 169 | "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", 170 | "dev": true, 171 | "license": "MIT", 172 | "dependencies": { 173 | "dequal": "^2.0.0" 174 | }, 175 | "funding": { 176 | "type": "github", 177 | "url": "https://github.com/sponsors/wooorm" 178 | } 179 | }, 180 | "node_modules/hast-util-to-html": { 181 | "version": "9.0.3", 182 | "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.3.tgz", 183 | "integrity": "sha512-M17uBDzMJ9RPCqLMO92gNNUDuBSq10a25SDBI08iCCxmorf4Yy6sYHK57n9WAbRAAaU+DuR4W6GN9K4DFZesYg==", 184 | "dev": true, 185 | "license": "MIT", 186 | "dependencies": { 187 | "@types/hast": "^3.0.0", 188 | "@types/unist": "^3.0.0", 189 | "ccount": "^2.0.0", 190 | "comma-separated-tokens": "^2.0.0", 191 | "hast-util-whitespace": "^3.0.0", 192 | "html-void-elements": "^3.0.0", 193 | "mdast-util-to-hast": "^13.0.0", 194 | "property-information": "^6.0.0", 195 | "space-separated-tokens": "^2.0.0", 196 | "stringify-entities": "^4.0.0", 197 | "zwitch": "^2.0.4" 198 | }, 199 | "funding": { 200 | "type": "opencollective", 201 | "url": "https://opencollective.com/unified" 202 | } 203 | }, 204 | "node_modules/hast-util-whitespace": { 205 | "version": "3.0.0", 206 | "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", 207 | "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", 208 | "dev": true, 209 | "license": "MIT", 210 | "dependencies": { 211 | "@types/hast": "^3.0.0" 212 | }, 213 | "funding": { 214 | "type": "opencollective", 215 | "url": "https://opencollective.com/unified" 216 | } 217 | }, 218 | "node_modules/html-void-elements": { 219 | "version": "3.0.0", 220 | "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", 221 | "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", 222 | "dev": true, 223 | "license": "MIT", 224 | "funding": { 225 | "type": "github", 226 | "url": "https://github.com/sponsors/wooorm" 227 | } 228 | }, 229 | "node_modules/mdast-util-to-hast": { 230 | "version": "13.2.0", 231 | "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", 232 | "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", 233 | "dev": true, 234 | "license": "MIT", 235 | "dependencies": { 236 | "@types/hast": "^3.0.0", 237 | "@types/mdast": "^4.0.0", 238 | "@ungap/structured-clone": "^1.0.0", 239 | "devlop": "^1.0.0", 240 | "micromark-util-sanitize-uri": "^2.0.0", 241 | "trim-lines": "^3.0.0", 242 | "unist-util-position": "^5.0.0", 243 | "unist-util-visit": "^5.0.0", 244 | "vfile": "^6.0.0" 245 | }, 246 | "funding": { 247 | "type": "opencollective", 248 | "url": "https://opencollective.com/unified" 249 | } 250 | }, 251 | "node_modules/micromark-util-character": { 252 | "version": "2.1.0", 253 | "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", 254 | "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", 255 | "dev": true, 256 | "funding": [ 257 | { 258 | "type": "GitHub Sponsors", 259 | "url": "https://github.com/sponsors/unifiedjs" 260 | }, 261 | { 262 | "type": "OpenCollective", 263 | "url": "https://opencollective.com/unified" 264 | } 265 | ], 266 | "license": "MIT", 267 | "dependencies": { 268 | "micromark-util-symbol": "^2.0.0", 269 | "micromark-util-types": "^2.0.0" 270 | } 271 | }, 272 | "node_modules/micromark-util-encode": { 273 | "version": "2.0.0", 274 | "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.0.tgz", 275 | "integrity": "sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==", 276 | "dev": true, 277 | "funding": [ 278 | { 279 | "type": "GitHub Sponsors", 280 | "url": "https://github.com/sponsors/unifiedjs" 281 | }, 282 | { 283 | "type": "OpenCollective", 284 | "url": "https://opencollective.com/unified" 285 | } 286 | ], 287 | "license": "MIT" 288 | }, 289 | "node_modules/micromark-util-sanitize-uri": { 290 | "version": "2.0.0", 291 | "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.0.tgz", 292 | "integrity": "sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==", 293 | "dev": true, 294 | "funding": [ 295 | { 296 | "type": "GitHub Sponsors", 297 | "url": "https://github.com/sponsors/unifiedjs" 298 | }, 299 | { 300 | "type": "OpenCollective", 301 | "url": "https://opencollective.com/unified" 302 | } 303 | ], 304 | "license": "MIT", 305 | "dependencies": { 306 | "micromark-util-character": "^2.0.0", 307 | "micromark-util-encode": "^2.0.0", 308 | "micromark-util-symbol": "^2.0.0" 309 | } 310 | }, 311 | "node_modules/micromark-util-symbol": { 312 | "version": "2.0.0", 313 | "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", 314 | "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", 315 | "dev": true, 316 | "funding": [ 317 | { 318 | "type": "GitHub Sponsors", 319 | "url": "https://github.com/sponsors/unifiedjs" 320 | }, 321 | { 322 | "type": "OpenCollective", 323 | "url": "https://opencollective.com/unified" 324 | } 325 | ], 326 | "license": "MIT" 327 | }, 328 | "node_modules/micromark-util-types": { 329 | "version": "2.0.0", 330 | "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.0.tgz", 331 | "integrity": "sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==", 332 | "dev": true, 333 | "funding": [ 334 | { 335 | "type": "GitHub Sponsors", 336 | "url": "https://github.com/sponsors/unifiedjs" 337 | }, 338 | { 339 | "type": "OpenCollective", 340 | "url": "https://opencollective.com/unified" 341 | } 342 | ], 343 | "license": "MIT" 344 | }, 345 | "node_modules/oniguruma-to-js": { 346 | "version": "0.4.3", 347 | "resolved": "https://registry.npmjs.org/oniguruma-to-js/-/oniguruma-to-js-0.4.3.tgz", 348 | "integrity": "sha512-X0jWUcAlxORhOqqBREgPMgnshB7ZGYszBNspP+tS9hPD3l13CdaXcHbgImoHUHlrvGx/7AvFEkTRhAGYh+jzjQ==", 349 | "dev": true, 350 | "license": "MIT", 351 | "dependencies": { 352 | "regex": "^4.3.2" 353 | }, 354 | "funding": { 355 | "url": "https://github.com/sponsors/antfu" 356 | } 357 | }, 358 | "node_modules/property-information": { 359 | "version": "6.5.0", 360 | "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", 361 | "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", 362 | "dev": true, 363 | "license": "MIT", 364 | "funding": { 365 | "type": "github", 366 | "url": "https://github.com/sponsors/wooorm" 367 | } 368 | }, 369 | "node_modules/regex": { 370 | "version": "4.4.0", 371 | "resolved": "https://registry.npmjs.org/regex/-/regex-4.4.0.tgz", 372 | "integrity": "sha512-uCUSuobNVeqUupowbdZub6ggI5/JZkYyJdDogddJr60L764oxC2pMZov1fQ3wM9bdyzUILDG+Sqx6NAKAz9rKQ==", 373 | "dev": true, 374 | "license": "MIT" 375 | }, 376 | "node_modules/shiki": { 377 | "version": "1.22.2", 378 | "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.22.2.tgz", 379 | "integrity": "sha512-3IZau0NdGKXhH2bBlUk4w1IHNxPh6A5B2sUpyY+8utLu2j/h1QpFkAaUA1bAMxOWWGtTWcAh531vnS4NJKS/lA==", 380 | "dev": true, 381 | "license": "MIT", 382 | "dependencies": { 383 | "@shikijs/core": "1.22.2", 384 | "@shikijs/engine-javascript": "1.22.2", 385 | "@shikijs/engine-oniguruma": "1.22.2", 386 | "@shikijs/types": "1.22.2", 387 | "@shikijs/vscode-textmate": "^9.3.0", 388 | "@types/hast": "^3.0.4" 389 | } 390 | }, 391 | "node_modules/space-separated-tokens": { 392 | "version": "2.0.2", 393 | "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", 394 | "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", 395 | "dev": true, 396 | "license": "MIT", 397 | "funding": { 398 | "type": "github", 399 | "url": "https://github.com/sponsors/wooorm" 400 | } 401 | }, 402 | "node_modules/stringify-entities": { 403 | "version": "4.0.4", 404 | "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", 405 | "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", 406 | "dev": true, 407 | "license": "MIT", 408 | "dependencies": { 409 | "character-entities-html4": "^2.0.0", 410 | "character-entities-legacy": "^3.0.0" 411 | }, 412 | "funding": { 413 | "type": "github", 414 | "url": "https://github.com/sponsors/wooorm" 415 | } 416 | }, 417 | "node_modules/trim-lines": { 418 | "version": "3.0.1", 419 | "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", 420 | "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", 421 | "dev": true, 422 | "license": "MIT", 423 | "funding": { 424 | "type": "github", 425 | "url": "https://github.com/sponsors/wooorm" 426 | } 427 | }, 428 | "node_modules/unist-util-is": { 429 | "version": "6.0.0", 430 | "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", 431 | "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", 432 | "dev": true, 433 | "license": "MIT", 434 | "dependencies": { 435 | "@types/unist": "^3.0.0" 436 | }, 437 | "funding": { 438 | "type": "opencollective", 439 | "url": "https://opencollective.com/unified" 440 | } 441 | }, 442 | "node_modules/unist-util-position": { 443 | "version": "5.0.0", 444 | "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", 445 | "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", 446 | "dev": true, 447 | "license": "MIT", 448 | "dependencies": { 449 | "@types/unist": "^3.0.0" 450 | }, 451 | "funding": { 452 | "type": "opencollective", 453 | "url": "https://opencollective.com/unified" 454 | } 455 | }, 456 | "node_modules/unist-util-stringify-position": { 457 | "version": "4.0.0", 458 | "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", 459 | "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", 460 | "dev": true, 461 | "license": "MIT", 462 | "dependencies": { 463 | "@types/unist": "^3.0.0" 464 | }, 465 | "funding": { 466 | "type": "opencollective", 467 | "url": "https://opencollective.com/unified" 468 | } 469 | }, 470 | "node_modules/unist-util-visit": { 471 | "version": "5.0.0", 472 | "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", 473 | "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", 474 | "dev": true, 475 | "license": "MIT", 476 | "dependencies": { 477 | "@types/unist": "^3.0.0", 478 | "unist-util-is": "^6.0.0", 479 | "unist-util-visit-parents": "^6.0.0" 480 | }, 481 | "funding": { 482 | "type": "opencollective", 483 | "url": "https://opencollective.com/unified" 484 | } 485 | }, 486 | "node_modules/unist-util-visit-parents": { 487 | "version": "6.0.1", 488 | "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", 489 | "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", 490 | "dev": true, 491 | "license": "MIT", 492 | "dependencies": { 493 | "@types/unist": "^3.0.0", 494 | "unist-util-is": "^6.0.0" 495 | }, 496 | "funding": { 497 | "type": "opencollective", 498 | "url": "https://opencollective.com/unified" 499 | } 500 | }, 501 | "node_modules/vfile": { 502 | "version": "6.0.3", 503 | "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", 504 | "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", 505 | "dev": true, 506 | "license": "MIT", 507 | "dependencies": { 508 | "@types/unist": "^3.0.0", 509 | "vfile-message": "^4.0.0" 510 | }, 511 | "funding": { 512 | "type": "opencollective", 513 | "url": "https://opencollective.com/unified" 514 | } 515 | }, 516 | "node_modules/vfile-message": { 517 | "version": "4.0.2", 518 | "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", 519 | "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", 520 | "dev": true, 521 | "license": "MIT", 522 | "dependencies": { 523 | "@types/unist": "^3.0.0", 524 | "unist-util-stringify-position": "^4.0.0" 525 | }, 526 | "funding": { 527 | "type": "opencollective", 528 | "url": "https://opencollective.com/unified" 529 | } 530 | }, 531 | "node_modules/zwitch": { 532 | "version": "2.0.4", 533 | "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", 534 | "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", 535 | "dev": true, 536 | "license": "MIT", 537 | "funding": { 538 | "type": "github", 539 | "url": "https://github.com/sponsors/wooorm" 540 | } 541 | } 542 | } 543 | } 544 | -------------------------------------------------------------------------------- /lib/link_equipment_web/components/core_components.ex: -------------------------------------------------------------------------------- 1 | defmodule LinkEquipmentWeb.CoreComponents do 2 | @moduledoc """ 3 | Provides core UI components. 4 | 5 | At first glance, this module may seem daunting, but its goal is to provide 6 | core building blocks for your application, such as modals, tables, and 7 | forms. The components consist mostly of markup and are well-documented 8 | with doc strings and declarative assigns. You may customize and style 9 | them in any way you want, based on your application growth and needs. 10 | 11 | The default components use Tailwind CSS, a utility-first CSS framework. 12 | See the [Tailwind CSS documentation](https://tailwindcss.com) to learn 13 | how to customize them or feel free to swap in another framework altogether. 14 | 15 | Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage. 16 | """ 17 | use Phoenix.Component 18 | 19 | alias Phoenix.HTML.FormField 20 | alias Phoenix.LiveView.JS 21 | 22 | @doc """ 23 | Renders a modal. 24 | 25 | ## Examples 26 | 27 | <.modal id="confirm-modal"> 28 | This is a modal. 29 | 30 | 31 | JS commands may be passed to the `:on_cancel` to configure 32 | the closing/cancel event, for example: 33 | 34 | <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}> 35 | This is another modal. 36 | 37 | 38 | """ 39 | attr :id, :string, required: true 40 | attr :show, :boolean, default: false 41 | attr :on_cancel, JS, default: %JS{} 42 | slot :inner_block, required: true 43 | 44 | def modal(assigns) do 45 | ~H""" 46 | 325 | """ 326 | end 327 | 328 | def input(%{type: "select"} = assigns) do 329 | ~H""" 330 |
    331 | <.label for={@id}><%= @label %> 332 | 342 | <.error :for={msg <- @errors}><%= msg %> 343 |
    344 | """ 345 | end 346 | 347 | def input(%{type: "textarea"} = assigns) do 348 | ~H""" 349 |
    350 | <.label for={@id}><%= @label %> 351 | 361 | <.error :for={msg <- @errors}><%= msg %> 362 |
    363 | """ 364 | end 365 | 366 | # All other inputs text, datetime-local, url, password, etc. are handled here... 367 | def input(assigns) do 368 | ~H""" 369 |
    370 | <.label for={@id}><%= @label %> 371 | 383 | <.error :for={msg <- @errors}><%= msg %> 384 |
    385 | """ 386 | end 387 | 388 | @doc """ 389 | Renders a label. 390 | """ 391 | attr :for, :string, default: nil 392 | slot :inner_block, required: true 393 | 394 | def label(assigns) do 395 | ~H""" 396 | 399 | """ 400 | end 401 | 402 | @doc """ 403 | Generates a generic error message. 404 | """ 405 | slot :inner_block, required: true 406 | 407 | def error(assigns) do 408 | ~H""" 409 |

    410 | <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" /> 411 | <%= render_slot(@inner_block) %> 412 |

    413 | """ 414 | end 415 | 416 | @doc """ 417 | Renders a header with title. 418 | """ 419 | attr :class, :string, default: nil 420 | 421 | slot :inner_block, required: true 422 | slot :subtitle 423 | slot :actions 424 | 425 | def header(assigns) do 426 | ~H""" 427 |
    428 |
    429 |

    430 | <%= render_slot(@inner_block) %> 431 |

    432 |

    433 | <%= render_slot(@subtitle) %> 434 |

    435 |
    436 |
    <%= render_slot(@actions) %>
    437 |
    438 | """ 439 | end 440 | 441 | @doc ~S""" 442 | Renders a table with generic styling. 443 | 444 | ## Examples 445 | 446 | <.table id="users" rows={@users}> 447 | <:col :let={user} label="id"><%= user.id %> 448 | <:col :let={user} label="username"><%= user.username %> 449 | 450 | """ 451 | attr :id, :string, required: true 452 | attr :rows, :list, required: true 453 | attr :row_id, :any, default: nil, doc: "the function for generating the row id" 454 | attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row" 455 | 456 | attr :row_item, :any, 457 | default: &Function.identity/1, 458 | doc: "the function for mapping each row before calling the :col and :action slots" 459 | 460 | slot :col, required: true do 461 | attr :label, :string 462 | end 463 | 464 | slot :action, doc: "the slot for showing user actions in the last table column" 465 | 466 | def table(assigns) do 467 | assigns = 468 | with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do 469 | assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) 470 | end 471 | 472 | ~H""" 473 |
    474 | 475 | 476 | 477 | 478 | 481 | 482 | 483 | 488 | 489 | 501 | 512 | 513 | 514 |
    <%= col[:label] %> 479 | Actions 480 |
    494 |
    495 | 496 | 497 | <%= render_slot(col, @row_item.(row)) %> 498 | 499 |
    500 |
    502 |
    503 | 504 | 508 | <%= render_slot(action, @row_item.(row)) %> 509 | 510 |
    511 |
    515 |
    516 | """ 517 | end 518 | 519 | @doc """ 520 | Renders a data list. 521 | 522 | ## Examples 523 | 524 | <.list> 525 | <:item title="Title"><%= @post.title %> 526 | <:item title="Views"><%= @post.views %> 527 | 528 | """ 529 | slot :item, required: true do 530 | attr :title, :string, required: true 531 | end 532 | 533 | def list(assigns) do 534 | ~H""" 535 |
    536 |
    537 |
    538 |
    <%= item.title %>
    539 |
    <%= render_slot(item) %>
    540 |
    541 |
    542 |
    543 | """ 544 | end 545 | 546 | @doc """ 547 | Renders a back navigation link. 548 | 549 | ## Examples 550 | 551 | <.back navigate={~p"/posts"}>Back to posts 552 | """ 553 | attr :navigate, :any, required: true 554 | slot :inner_block, required: true 555 | 556 | def back(assigns) do 557 | ~H""" 558 |
    559 | <.link 560 | navigate={@navigate} 561 | class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700" 562 | > 563 | <.icon name="hero-arrow-left-solid" class="h-3 w-3" /> 564 | <%= render_slot(@inner_block) %> 565 | 566 |
    567 | """ 568 | end 569 | 570 | @doc """ 571 | Renders a [Heroicon](https://heroicons.com). 572 | 573 | Heroicons come in three styles – outline, solid, and mini. 574 | By default, the outline style is used, but solid and mini may 575 | be applied by using the `-solid` and `-mini` suffix. 576 | 577 | You can customize the size and colors of the icons by setting 578 | width, height, and background color classes. 579 | 580 | Icons are extracted from the `deps/heroicons` directory and bundled within 581 | your compiled app.css by the plugin in your `assets/tailwind.config.js`. 582 | 583 | ## Examples 584 | 585 | <.icon name="hero-x-mark-solid" /> 586 | <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" /> 587 | """ 588 | attr :name, :string, required: true 589 | attr :class, :string, default: nil 590 | 591 | def icon(%{name: "hero-" <> _} = assigns) do 592 | ~H""" 593 | 594 | """ 595 | end 596 | 597 | ## JS Commands 598 | 599 | def show(js \\ %JS{}, selector) do 600 | JS.show(js, 601 | to: selector, 602 | time: 300, 603 | transition: 604 | {"transition-all transform ease-out duration-300", "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", 605 | "opacity-100 translate-y-0 sm:scale-100"} 606 | ) 607 | end 608 | 609 | def hide(js \\ %JS{}, selector) do 610 | JS.hide(js, 611 | to: selector, 612 | time: 200, 613 | transition: 614 | {"transition-all transform ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100", 615 | "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} 616 | ) 617 | end 618 | 619 | def show_modal(js \\ %JS{}, id) when is_binary(id) do 620 | js 621 | |> JS.show(to: "##{id}") 622 | |> JS.show( 623 | to: "##{id}-bg", 624 | time: 300, 625 | transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"} 626 | ) 627 | |> show("##{id}-container") 628 | |> JS.add_class("overflow-hidden", to: "body") 629 | |> JS.focus_first(to: "##{id}-content") 630 | end 631 | 632 | def hide_modal(js \\ %JS{}, id) do 633 | js 634 | |> JS.hide( 635 | to: "##{id}-bg", 636 | transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"} 637 | ) 638 | |> hide("##{id}-container") 639 | |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"}) 640 | |> JS.remove_class("overflow-hidden", to: "body") 641 | |> JS.pop_focus() 642 | end 643 | 644 | @doc """ 645 | Translates an error message using gettext. 646 | """ 647 | def translate_error({msg, opts}) do 648 | # You can make use of gettext to translate error messages by 649 | # uncommenting and adjusting the following code: 650 | 651 | # if count = opts[:count] do 652 | # Gettext.dngettext(LinkEquipmentWeb.Gettext, "errors", msg, msg, count, opts) 653 | # else 654 | # Gettext.dgettext(LinkEquipmentWeb.Gettext, "errors", msg, opts) 655 | # end 656 | 657 | Enum.reduce(opts, msg, fn {key, value}, acc -> 658 | String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end) 659 | end) 660 | end 661 | 662 | @doc """ 663 | Translates the errors for a field from a keyword list of errors. 664 | """ 665 | def translate_errors(errors, field) when is_list(errors) do 666 | for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) 667 | end 668 | 669 | def async(%{assign: async_assign} = assigns) do 670 | cond do 671 | async_assign.loading && async_assign.ok? -> 672 | ~H""" 673 | <%= render_slot(@loading, @assign.loading) %> 674 | <%= render_slot(@inner_block, @assign.result) %> 675 | """ 676 | 677 | async_assign.loading -> 678 | ~H""" 679 | <%= render_slot(@loading, @assign.loading) %> 680 | """ 681 | 682 | async_assign.ok? -> 683 | ~H""" 684 | <%= render_slot(@inner_block, @assign.result) %> 685 | """ 686 | 687 | async_assign.failed -> 688 | ~H""" 689 | <%= render_slot(@failed, @assign.failed) %> 690 | """ 691 | end 692 | end 693 | end 694 | --------------------------------------------------------------------------------