├── .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 |
53 |
54 |
62 |
63 |
64 | <.focus_wrap
65 | id={"#{@id}-container"}
66 | phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")}
67 | phx-key="escape"
68 | phx-click-away={JS.exec("data-cancel", to: "##{@id}")}
69 | class="shadow-zinc-700/10 ring-zinc-700/10 relative hidden rounded-2xl bg-white p-14 shadow-lg ring-1 transition"
70 | >
71 |
72 |
80 |
81 |
82 | <%= render_slot(@inner_block) %>
83 |
84 |
85 |
86 |
87 |
88 |
89 | """
90 | end
91 |
92 | @doc """
93 | Renders flash notices.
94 |
95 | ## Examples
96 |
97 | <.flash kind={:info} flash={@flash} />
98 | <.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!
99 | """
100 | attr :id, :string, doc: "the optional id of flash container"
101 | attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
102 | attr :title, :string, default: nil
103 | attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
104 | attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
105 |
106 | slot :inner_block, doc: "the optional inner block that renders the flash message"
107 |
108 | def flash(assigns) do
109 | assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
110 |
111 | ~H"""
112 | hide("##{@id}")}
116 | role="alert"
117 | class={[
118 | "fixed top-2 right-2 mr-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1",
119 | @kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900",
120 | @kind == :error && "bg-rose-50 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
121 | ]}
122 | {@rest}
123 | >
124 |
125 | <.icon :if={@kind == :info} name="hero-information-circle-mini" class="h-4 w-4" />
126 | <.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="h-4 w-4" />
127 | <%= @title %>
128 |
129 |
<%= msg %>
130 |
133 |
134 | """
135 | end
136 |
137 | @doc """
138 | Shows the flash group with standard titles and content.
139 |
140 | ## Examples
141 |
142 | <.flash_group flash={@flash} />
143 | """
144 | attr :flash, :map, required: true, doc: "the map of flash messages"
145 | attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
146 |
147 | def flash_group(assigns) do
148 | ~H"""
149 |
150 | <.flash kind={:info} title="Success!" flash={@flash} />
151 | <.flash kind={:error} title="Error!" flash={@flash} />
152 | <.flash
153 | id="client-error"
154 | kind={:error}
155 | title="We can't find the internet"
156 | phx-disconnected={show(".phx-client-error #client-error")}
157 | phx-connected={hide("#client-error")}
158 | hidden
159 | >
160 | Attempting to reconnect <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
161 |
162 |
163 | <.flash
164 | id="server-error"
165 | kind={:error}
166 | title="Something went wrong!"
167 | phx-disconnected={show(".phx-server-error #server-error")}
168 | phx-connected={hide("#server-error")}
169 | hidden
170 | >
171 | Hang in there while we get back on track
172 | <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
173 |
174 |
175 | """
176 | end
177 |
178 | @doc """
179 | Renders a simple form.
180 |
181 | ## Examples
182 |
183 | <.simple_form for={@form} phx-change="validate" phx-submit="save">
184 | <.input field={@form[:email]} label="Email"/>
185 | <.input field={@form[:username]} label="Username" />
186 | <:actions>
187 | <.button>Save
188 |
189 |
190 | """
191 | attr :for, :any, required: true, doc: "the data structure for the form"
192 | attr :as, :any, default: nil, doc: "the server side parameter to collect all input under"
193 |
194 | attr :rest, :global,
195 | include: ~w(autocomplete name rel action enctype method novalidate target multipart),
196 | doc: "the arbitrary HTML attributes to apply to the form tag"
197 |
198 | slot :inner_block, required: true
199 | slot :actions, doc: "the slot for form actions, such as a submit button"
200 |
201 | def simple_form(assigns) do
202 | ~H"""
203 | <.form :let={f} for={@for} as={@as} {@rest}>
204 |
205 | <%= render_slot(@inner_block, f) %>
206 |
207 | <%= render_slot(action, f) %>
208 |
209 |
210 |
211 | """
212 | end
213 |
214 | @doc """
215 | Renders a button.
216 |
217 | ## Examples
218 |
219 | <.button>Send!
220 | <.button phx-click="go" class="ml-2">Send!
221 | """
222 | attr :type, :string, default: nil
223 | attr :class, :string, default: nil
224 | attr :rest, :global, include: ~w(disabled form name value)
225 |
226 | slot :inner_block, required: true
227 |
228 | def button(assigns) do
229 | ~H"""
230 |
241 | """
242 | end
243 |
244 | @doc """
245 | Renders an input with label and error messages.
246 |
247 | A `Phoenix.HTML.FormField` may be passed as argument,
248 | which is used to retrieve the input name, id, and values.
249 | Otherwise all attributes may be passed explicitly.
250 |
251 | ## Types
252 |
253 | This function accepts all HTML input types, considering that:
254 |
255 | * You may also set `type="select"` to render a `