├── test ├── test_helper.exs ├── todoish_web │ ├── views │ │ ├── page_view_test.exs │ │ ├── layout_view_test.exs │ │ └── error_view_test.exs │ └── controllers │ │ └── page_controller_test.exs └── support │ └── conn_case.ex ├── rel └── overlays │ └── bin │ ├── server.bat │ └── server ├── lib ├── todoish_web │ ├── templates │ │ ├── auth │ │ │ └── failure.html.heex │ │ ├── .DS_Store │ │ ├── layout │ │ │ ├── app.html.heex │ │ │ ├── live.html.heex │ │ │ └── root.html.heex │ │ ├── page │ │ │ └── index.html.heex │ │ └── profile │ │ │ └── profile.html.heex │ ├── views │ │ ├── auth_view.ex │ │ ├── page_view.ex │ │ ├── profile_view.ex │ │ ├── layout_view.ex │ │ ├── error_view.ex │ │ └── error_helpers.ex │ ├── controllers │ │ ├── auth_controller.ex │ │ ├── profile_controller.ex │ │ └── page_controller.ex │ ├── telemetry.ex │ ├── endpoint.ex │ ├── router.ex │ └── live │ │ └── list.ex ├── todoish │ ├── entries.ex │ ├── repo.ex │ ├── entries │ │ ├── resources │ │ │ ├── token.ex │ │ │ ├── users_lists.ex │ │ │ ├── user.ex │ │ │ ├── list.ex │ │ │ └── item.ex │ │ └── registry.ex │ ├── release.ex │ ├── application.ex │ └── entries_server.ex ├── todoish.ex └── todoish_web.ex ├── priv ├── static │ ├── favicon.ico │ ├── images │ │ └── phoenix.png │ └── robots.txt ├── resource_snapshots │ ├── extensions.json │ └── repo │ │ ├── users │ │ └── 20230221171153.json │ │ ├── lists │ │ ├── 20220830033004.json │ │ ├── 20220830233010.json │ │ └── 20230221171153.json │ │ ├── users_lists │ │ └── 20230221195635.json │ │ ├── tokens │ │ └── 20230221171153.json │ │ └── items │ │ ├── 20220830033004.json │ │ └── 20230221171152.json └── repo │ └── migrations │ ├── 20220830233010_migrate_resources1.exs │ ├── 20230221171151_install_2_extensions.exs │ ├── 20230221195635_migrate_resources2.exs │ ├── 20220830033004_add_lists_and_items.exs │ └── 20230221171152_add_user_and_token.exs ├── .formatter.exs ├── .nova └── Configuration.json ├── README.md ├── config ├── test.exs ├── config.exs ├── prod.exs ├── runtime.exs └── dev.exs ├── .gitignore ├── fly.toml ├── .dockerignore ├── assets ├── tailwind.config.js ├── css │ ├── app.css │ └── phoenix.css ├── js │ └── app.js └── vendor │ └── topbar.js ├── mix.exs ├── Dockerfile └── mix.lock /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /rel/overlays/bin/server.bat: -------------------------------------------------------------------------------- 1 | set PHX_SERVER=true 2 | call "%~dp0\todoish" start 3 | -------------------------------------------------------------------------------- /lib/todoish_web/templates/auth/failure.html.heex: -------------------------------------------------------------------------------- 1 |

Authentication Error

2 | -------------------------------------------------------------------------------- /priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brettkolodny/todoish/HEAD/priv/static/favicon.ico -------------------------------------------------------------------------------- /lib/todoish_web/views/auth_view.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoishWeb.AuthView do 2 | use TodoishWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/todoish_web/views/page_view.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoishWeb.PageView do 2 | use TodoishWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/todoish_web/views/profile_view.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoishWeb.ProfileView do 2 | use TodoishWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /priv/resource_snapshots/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "installed": [ 3 | "uuid-ossp", 4 | "citext" 5 | ] 6 | } -------------------------------------------------------------------------------- /rel/overlays/bin/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd -P -- "$(dirname -- "$0")" 3 | PHX_SERVER=true exec ./todoish start 4 | -------------------------------------------------------------------------------- /priv/static/images/phoenix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brettkolodny/todoish/HEAD/priv/static/images/phoenix.png -------------------------------------------------------------------------------- /lib/todoish_web/templates/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brettkolodny/todoish/HEAD/lib/todoish_web/templates/.DS_Store -------------------------------------------------------------------------------- /test/todoish_web/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TodoishWeb.PageViewTest do 2 | use TodoishWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /lib/todoish/entries.ex: -------------------------------------------------------------------------------- 1 | defmodule Todoish.Entries do 2 | use Ash.Api 3 | 4 | resources do 5 | registry Todoish.Entries.Registry 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:phoenix, :ash, :ash_postgres, :ash_authentication_phoenix], 3 | inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /lib/todoish/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Todoish.Repo do 2 | use AshPostgres.Repo, otp_app: :todoish 3 | 4 | def installed_extensions do 5 | ["uuid-ossp", "citext"] 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /.nova/Configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "co.gwil.deno.config.enableLsp" : "false", 3 | "co.gwil.deno.config.enableUnstable" : "false", 4 | "co.gwil.deno.config.formatOnSave" : "false" 5 | } 6 | -------------------------------------------------------------------------------- /lib/todoish_web/templates/layout/app.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 3 | <%= @inner_content %> 4 |
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 | -------------------------------------------------------------------------------- /test/todoish_web/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TodoishWeb.PageControllerTest do 2 | use TodoishWeb.ConnCase 3 | 4 | test "GET /", %{conn: conn} do 5 | conn = get(conn, "/") 6 | assert html_response(conn, 200) =~ "Welcome to Phoenix!" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/todoish.ex: -------------------------------------------------------------------------------- 1 | defmodule Todoish do 2 | @moduledoc """ 3 | Todoish 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 | -------------------------------------------------------------------------------- /test/todoish_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TodoishWeb.LayoutViewTest do 2 | use TodoishWeb.ConnCase, async: true 3 | 4 | # When testing helpers, you may want to import Phoenix.HTML and 5 | # use functions such as safe_to_string() to convert the helper 6 | # result into an HTML string. 7 | # import Phoenix.HTML 8 | end 9 | -------------------------------------------------------------------------------- /lib/todoish/entries/resources/token.ex: -------------------------------------------------------------------------------- 1 | defmodule Todoish.Entries.Token do 2 | use Ash.Resource, 3 | data_layer: AshPostgres.DataLayer, 4 | extensions: [AshAuthentication.TokenResource] 5 | 6 | token do 7 | api Todoish.Entries 8 | end 9 | 10 | postgres do 11 | table "tokens" 12 | repo Todoish.Repo 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/todoish_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoishWeb.LayoutView do 2 | use TodoishWeb, :view 3 | import Phoenix.Component 4 | 5 | # Phoenix LiveDashboard is available only in development by default, 6 | # so we instruct Elixir to not warn if the dashboard route is missing. 7 | @compile {:no_warn_undefined, {Routes, :live_dashboard_path, 2}} 8 | end 9 | -------------------------------------------------------------------------------- /lib/todoish/entries/registry.ex: -------------------------------------------------------------------------------- 1 | defmodule Todoish.Entries.Registry do 2 | use Ash.Registry, extensions: [Ash.Registry.ResourceValidations] 3 | 4 | entries do 5 | entry Todoish.Entries.List 6 | entry Todoish.Entries.Item 7 | entry Todoish.Entries.User 8 | entry Todoish.Entries.Token 9 | entry Todoish.Entries.UsersLists 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/todoish_web/templates/layout/live.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 5 | 6 | 9 | 10 | <%= @inner_content %> 11 |
12 | -------------------------------------------------------------------------------- /test/todoish_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TodoishWeb.ErrorViewTest do 2 | use TodoishWeb.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(TodoishWeb.ErrorView, "404.html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(TodoishWeb.ErrorView, "500.html", []) == "Internal Server Error" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/todoish_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoishWeb.ErrorView do 2 | use TodoishWeb, :view 3 | 4 | # If you want to customize a particular status code 5 | # for a certain format, you may uncomment below. 6 | # def render("500.html", _assigns) do 7 | # "Internal Server Error" 8 | # end 9 | 10 | # By default, Phoenix returns the status message from 11 | # the template name. For example, "404.html" becomes 12 | # "Not Found". 13 | def template_not_found(template, _assigns) do 14 | Phoenix.Controller.status_message_from_template(template) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Todoish 2 | 3 | ![Todoish](https://user-images.githubusercontent.com/22826580/214425030-58798d72-2d1a-43c3-8a06-cec5cf001029.jpg) 4 | 5 | 6 | A real time sharable todo-list! Built with Elixir, Phoenix, LiveView, and Ash. 7 | 8 | Check out [Todoish live](https://todoi.sh/)! 9 | 10 | --- 11 | 12 | ## Development 13 | 14 | 1. Install Deps 15 | 16 | ```sh 17 | mix deps.get 18 | ``` 19 | 20 | 2. Setup Postgres on port `5455` 21 | 3. Setup DB 22 | ```sh 23 | mix ash_postgres.create 24 | mix ash_postgres.migrate 25 | ``` 26 | 27 | ## Deploy 28 | 29 | An instance of Todoish can be set up on fly from [this tutorial](https://fly.io/docs/elixir/). 30 | -------------------------------------------------------------------------------- /priv/repo/migrations/20220830233010_migrate_resources1.exs: -------------------------------------------------------------------------------- 1 | defmodule Todoish.Repo.Migrations.MigrateResources1 do 2 | @moduledoc """ 3 | Updates resources based on their most recent snapshots. 4 | 5 | This file was autogenerated with `mix ash_postgres.generate_migrations` 6 | """ 7 | 8 | use Ecto.Migration 9 | 10 | def up do 11 | alter table(:lists) do 12 | add :updated_at, :utc_datetime_usec, null: false, default: fragment("now()") 13 | add :inserted_at, :utc_datetime_usec, null: false, default: fragment("now()") 14 | end 15 | end 16 | 17 | def down do 18 | alter table(:lists) do 19 | remove :inserted_at 20 | remove :updated_at 21 | end 22 | end 23 | end -------------------------------------------------------------------------------- /priv/repo/migrations/20230221171151_install_2_extensions.exs: -------------------------------------------------------------------------------- 1 | defmodule Todoish.Repo.Migrations.Install2Extensions do 2 | @moduledoc """ 3 | Installs any extensions that are mentioned in the repo's `installed_extensions/0` callback 4 | 5 | This file was autogenerated with `mix ash_postgres.generate_migrations` 6 | """ 7 | 8 | use Ecto.Migration 9 | 10 | def up do 11 | execute("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"") 12 | execute("CREATE EXTENSION IF NOT EXISTS \"citext\"") 13 | end 14 | 15 | def down do 16 | # Uncomment this if you actually want to uninstall the extensions 17 | # when this migration is rolled back: 18 | # execute("DROP EXTENSION IF EXISTS \"uuid-ossp\"") 19 | # execute("DROP EXTENSION IF EXISTS \"citext\"") 20 | end 21 | end -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :todoish, TodoishWeb.Endpoint, 6 | http: [ip: {127, 0, 0, 1}, port: 4002], 7 | secret_key_base: "6XO/DAtv2i00BPW92c4S2lYjV7nnv/p+2GQ28ak/PcSPuYwJ0ORJbLfzhiNHgn4O", 8 | server: false 9 | 10 | config :todoish, Todoish.Repo, 11 | username: "postgres", 12 | password: "postgres", 13 | hostname: "localhost", 14 | database: "todoish_test#{System.get_env("MIX_TEST_PARTITION")}", 15 | pool: Ecto.Adapters.SQL.Sandbox, 16 | pool_size: 10 17 | 18 | # Print only warnings and errors during test 19 | config :logger, level: :warn 20 | 21 | # Initialize plugs at runtime for faster test compilation 22 | config :phoenix, :plug_init_mode, :runtime 23 | -------------------------------------------------------------------------------- /lib/todoish_web/controllers/auth_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoishWeb.AuthController do 2 | use TodoishWeb, :controller 3 | use AshAuthentication.Phoenix.Controller 4 | 5 | def success(conn, _activity, user, _token) do 6 | return_to = get_session(conn, :return_to) || "/" 7 | 8 | conn 9 | |> delete_session(:return_to) 10 | |> store_in_session(user) 11 | |> assign(:current_user, user) 12 | |> redirect(to: return_to) 13 | end 14 | 15 | def failure(conn, _activity, _reason) do 16 | conn 17 | |> put_status(401) 18 | |> render("failure.html") 19 | end 20 | 21 | def sign_out(conn, _params) do 22 | return_to = get_session(conn, :return_to) || "/" 23 | 24 | conn 25 | |> clear_session() 26 | |> redirect(to: return_to) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | todoish-*.tar 24 | 25 | # Ignore assets that are produced by build tools. 26 | /priv/static/assets/ 27 | 28 | # Ignore digested assets cache. 29 | /priv/static/cache_manifest.json 30 | 31 | # In case you use Node.js/npm, you want to ignore these. 32 | npm-debug.log 33 | /assets/node_modules/ 34 | 35 | -------------------------------------------------------------------------------- /lib/todoish/entries/resources/users_lists.ex: -------------------------------------------------------------------------------- 1 | defmodule Todoish.Entries.UsersLists do 2 | use Ash.Resource, data_layer: AshPostgres.DataLayer 3 | 4 | postgres do 5 | table "users_lists" 6 | repo Todoish.Repo 7 | end 8 | 9 | actions do 10 | defaults [:create, :read, :update, :destroy] 11 | 12 | create :new do 13 | argument :list_id, :uuid do 14 | allow_nil? false 15 | end 16 | 17 | argument :user_id, :uuid do 18 | allow_nil? false 19 | end 20 | 21 | change manage_relationship(:list_id, :list, type: :replace) 22 | change manage_relationship(:user_id, :user, type: :replace) 23 | end 24 | end 25 | 26 | attributes do 27 | uuid_primary_key :id 28 | end 29 | 30 | relationships do 31 | belongs_to :list, Todoish.Entries.List do 32 | allow_nil? true 33 | end 34 | 35 | belongs_to :user, Todoish.Entries.User do 36 | allow_nil? true 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/todoish_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoishWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | use Phoenix.HTML 7 | 8 | @doc """ 9 | Generates tag for inlined form input errors. 10 | """ 11 | def error_tag(form, field) do 12 | Enum.map(Keyword.get_values(form.errors, field), fn error -> 13 | content_tag(:span, translate_error(error), 14 | class: "invalid-feedback", 15 | phx_feedback_for: input_name(form, field) 16 | ) 17 | end) 18 | end 19 | 20 | @doc """ 21 | Translates an error message. 22 | """ 23 | def translate_error({msg, opts}) do 24 | # Because the error messages we show in our forms and APIs 25 | # are defined inside Ecto, we need to translate them dynamically. 26 | Enum.reduce(opts, msg, fn {key, value}, acc -> 27 | String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end) 28 | end) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for todoish on 2022-09-01T00:32:19-04:00 2 | 3 | app = "todoish" 4 | kill_signal = "SIGTERM" 5 | kill_timeout = 5 6 | processes = [] 7 | 8 | [deploy] 9 | #release_command = "/app/bin/todoish eval 'Todoish.Release.migrate'" 10 | 11 | [env] 12 | PHX_HOST = "todoish.fly.dev" 13 | PORT = "8080" 14 | 15 | [experimental] 16 | allowed_public_ports = [] 17 | auto_rollback = true 18 | 19 | [[services]] 20 | http_checks = [] 21 | internal_port = 8080 22 | processes = ["app"] 23 | protocol = "tcp" 24 | script_checks = [] 25 | [services.concurrency] 26 | hard_limit = 25 27 | soft_limit = 20 28 | type = "connections" 29 | 30 | [[services.ports]] 31 | force_https = true 32 | handlers = ["http"] 33 | port = 80 34 | 35 | [[services.ports]] 36 | handlers = ["tls", "http"] 37 | port = 443 38 | 39 | [[services.tcp_checks]] 40 | grace_period = "1s" 41 | interval = "15s" 42 | restart_limit = 0 43 | timeout = "2s" 44 | -------------------------------------------------------------------------------- /lib/todoish/release.ex: -------------------------------------------------------------------------------- 1 | # The following code is taken from https://github.com/ash-project/ash_hq 2 | 3 | defmodule Todoish.Release do 4 | @moduledoc """ 5 | Houses tasks that need to be executed in the released application (because mix is not present in releases). 6 | """ 7 | 8 | @app :todoish 9 | def migrate do 10 | load_app() 11 | 12 | for repo <- repos() do 13 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) 14 | end 15 | end 16 | 17 | def migrate_all do 18 | load_app() 19 | migrate() 20 | end 21 | 22 | def rollback(repo, version) do 23 | load_app() 24 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) 25 | end 26 | 27 | defp repos do 28 | apis() 29 | |> Enum.flat_map(fn api -> 30 | api 31 | |> Ash.Api.resources() 32 | |> Enum.map(&AshPostgres.repo/1) 33 | end) 34 | |> Enum.uniq() 35 | end 36 | 37 | defp apis do 38 | Application.fetch_env!(@app, :ash_apis) 39 | end 40 | 41 | defp load_app do 42 | Application.load(@app) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230221195635_migrate_resources2.exs: -------------------------------------------------------------------------------- 1 | defmodule Todoish.Repo.Migrations.MigrateResources2 do 2 | @moduledoc """ 3 | Updates resources based on their most recent snapshots. 4 | 5 | This file was autogenerated with `mix ash_postgres.generate_migrations` 6 | """ 7 | 8 | use Ecto.Migration 9 | 10 | def up do 11 | create table(:users_lists, primary_key: false) do 12 | add :id, :uuid, null: false, default: fragment("uuid_generate_v4()"), primary_key: true 13 | 14 | add :list_id, 15 | references(:lists, 16 | column: :id, 17 | name: "users_lists_list_id_fkey", 18 | type: :uuid, 19 | prefix: "public" 20 | ) 21 | 22 | add :user_id, 23 | references(:users, 24 | column: :id, 25 | name: "users_lists_user_id_fkey", 26 | type: :uuid, 27 | prefix: "public" 28 | ) 29 | end 30 | end 31 | 32 | def down do 33 | drop constraint(:users_lists, "users_lists_user_id_fkey") 34 | 35 | drop constraint(:users_lists, "users_lists_list_id_fkey") 36 | 37 | drop table(:users_lists) 38 | end 39 | end -------------------------------------------------------------------------------- /lib/todoish/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Todoish.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | children = [ 11 | # Start the Telemetry supervisor 12 | TodoishWeb.Telemetry, 13 | # Start the PubSub system 14 | {Phoenix.PubSub, name: Todoish.PubSub}, 15 | # Start the Endpoint (http/https) 16 | TodoishWeb.Endpoint, 17 | # Start a worker by calling: Todoish.Worker.start_link(arg) 18 | # {Todoish.Worker, arg} 19 | Todoish.Repo, 20 | {Todoish.EntriesServer, name: :entries_server} 21 | ] 22 | 23 | # See https://hexdocs.pm/elixir/Supervisor.html 24 | # for other strategies and supported options 25 | opts = [strategy: :one_for_one, name: Todoish.Supervisor] 26 | Supervisor.start_link(children, opts) 27 | end 28 | 29 | # Tell Phoenix to update the endpoint configuration 30 | # whenever the application is updated. 31 | @impl true 32 | def config_change(changed, _new, removed) do 33 | TodoishWeb.Endpoint.config_change(changed, removed) 34 | :ok 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoishWeb.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 TodoishWeb.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with connections 23 | import Plug.Conn 24 | import Phoenix.ConnTest 25 | import TodoishWeb.ConnCase 26 | 27 | alias TodoishWeb.Router.Helpers, as: Routes 28 | 29 | # The default endpoint for testing 30 | @endpoint TodoishWeb.Endpoint 31 | end 32 | end 33 | 34 | setup _tags do 35 | {:ok, conn: Phoenix.ConnTest.build_conn()} 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/todoish/entries/resources/user.ex: -------------------------------------------------------------------------------- 1 | defmodule Todoish.Entries.User do 2 | use Ash.Resource, 3 | data_layer: AshPostgres.DataLayer, 4 | extensions: [AshAuthentication] 5 | 6 | attributes do 7 | uuid_primary_key :id 8 | attribute :email, :ci_string, allow_nil?: false 9 | attribute :hashed_password, :string, allow_nil?: false, sensitive?: true 10 | end 11 | 12 | authentication do 13 | api Todoish.Entries 14 | 15 | strategies do 16 | password :password do 17 | identity_field(:email) 18 | end 19 | end 20 | 21 | tokens do 22 | enabled?(true) 23 | token_resource(Todoish.Entries.Token) 24 | 25 | signing_secret(fn _, _ -> 26 | {:ok, env} = Application.fetch_env(:todoish, TodoishWeb.Endpoint) 27 | {:ok, env[:secret_key_base]} 28 | end) 29 | end 30 | end 31 | 32 | actions do 33 | defaults [:read] 34 | end 35 | 36 | relationships do 37 | many_to_many :lists, Todoish.Entries.List do 38 | through Todoish.Entries.UsersLists 39 | source_attribute_on_join_resource :user_id 40 | destination_attribute_on_join_resource :list_id 41 | end 42 | end 43 | 44 | postgres do 45 | table "users" 46 | repo Todoish.Repo 47 | end 48 | 49 | identities do 50 | identity :unique_email, [:email] 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/todoish_web/controllers/profile_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoishWeb.ProfileController do 2 | use TodoishWeb, :controller 3 | 4 | require Ash.Query 5 | 6 | def profile(conn, _params) do 7 | if conn.assigns.current_user do 8 | user = conn.assigns.current_user 9 | 10 | user = 11 | Todoish.Entries.User 12 | |> Ash.Query.filter(id == ^user.id) 13 | |> Ash.Query.limit(1) 14 | |> Ash.Query.load(:lists) 15 | |> Todoish.Entries.read_one!() 16 | 17 | conn 18 | |> assign(:lists, user.lists) 19 | |> render("profile.html") 20 | else 21 | conn 22 | |> put_session(:return_to, "/profile") 23 | |> redirect(to: "/sign-in") 24 | end 25 | end 26 | 27 | def remove_list(conn, %{"list_id" => list_id}) do 28 | if conn.assigns.current_user do 29 | user = conn.assigns.current_user 30 | 31 | Todoish.Entries.UsersLists 32 | |> Ash.Query.filter(user_id == ^user.id and list_id == ^list_id) 33 | |> Ash.Query.limit(1) 34 | |> Todoish.Entries.read_one!() 35 | |> Ash.Changeset.for_destroy(:destroy) 36 | |> Todoish.Entries.destroy!() 37 | 38 | redirect(conn, to: "/profile") 39 | else 40 | conn 41 | |> put_session(:return_to, "/profile") 42 | |> redirect(to: "/sign-in") 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/todoish_web/templates/page/index.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 |
Todoish
5 |
6 |

7 |

Share todos, grocery lists, or anything else!

8 |
9 |
10 | <%= form_for @conn, Routes.page_path(@conn, :new), 11 | [as: :new_list, class: ["flex flex-col gap-4 w-full"]], 12 | fn f -> 13 | %> 14 | <%= text_input f, :title, [placeholder: "My awesome list!", class: ["w-full h-12 rounded-md bg-base-100"]] %> 15 | <%= textarea f, :description, [placeholder: "My awesome list's description!", class: ["w-full h-24 rounded-md bg-base-100 mb-4"]] %> 16 | 17 | <%= submit "Create a Todoish!", [class: ["bg-primary-400 text-white h-12 text-lg rounded-md hover:bg-primary-500 transition-colors"]] %> 18 | <% end %> 19 |
20 |
21 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # This file excludes paths from the Docker build context. 2 | # 3 | # By default, Docker's build context includes all files (and folders) in the 4 | # current directory. Even if a file isn't copied into the container it is still sent to 5 | # the Docker daemon. 6 | # 7 | # There are multiple reasons to exclude files from the build context: 8 | # 9 | # 1. Prevent nested folders from being copied into the container (ex: exclude 10 | # /assets/node_modules when copying /assets) 11 | # 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc) 12 | # 3. Avoid sending files containing sensitive information 13 | # 14 | # More information on using .dockerignore is available here: 15 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file 16 | 17 | .dockerignore 18 | 19 | # Ignore git, but keep git HEAD and refs to access current commit hash if needed: 20 | # 21 | # $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat 22 | # d0b8727759e1e0e7aa3d41707d12376e373d5ecc 23 | .git 24 | !.git/HEAD 25 | !.git/refs 26 | 27 | # Common development/test artifacts 28 | /cover/ 29 | /doc/ 30 | /test/ 31 | /tmp/ 32 | .elixir_ls 33 | 34 | # Mix artifacts 35 | /_build/ 36 | /deps/ 37 | *.ez 38 | 39 | # Generated on crash by the VM 40 | erl_crash.dump 41 | 42 | # Static artifacts - These should be fetched and built inside the Docker image 43 | /assets/node_modules/ 44 | /priv/static/assets/ 45 | /priv/static/cache_manifest.json 46 | -------------------------------------------------------------------------------- /lib/todoish/entries/resources/list.ex: -------------------------------------------------------------------------------- 1 | defmodule Todoish.Entries.List do 2 | use Ash.Resource, data_layer: AshPostgres.DataLayer, notifiers: [Ash.Notifier.PubSub] 3 | 4 | postgres do 5 | table("lists") 6 | repo(Todoish.Repo) 7 | end 8 | 9 | actions do 10 | defaults([:create, :read, :update, :destroy]) 11 | 12 | create :new do 13 | accept([:title, :url_id, :description]) 14 | end 15 | end 16 | 17 | attributes do 18 | uuid_primary_key(:id) 19 | 20 | timestamps() 21 | 22 | attribute :title, :string do 23 | default("A Todoish List") 24 | 25 | # allow_nil? false 26 | end 27 | 28 | attribute :description, :string do 29 | default("Add items to get started!") 30 | 31 | # allow_nil? false 32 | end 33 | 34 | attribute :url_id, :string do 35 | allow_nil?(false) 36 | end 37 | 38 | identities do 39 | identity(:unique_url_id, [:url_id], pre_check_with: Todoish.Entries) 40 | end 41 | end 42 | 43 | pub_sub do 44 | module(TodoishWeb.Endpoint) 45 | prefix("list") 46 | broadcast_type(:notification) 47 | 48 | publish(:new, ["created"], event: "new-list") 49 | end 50 | 51 | relationships do 52 | has_many :items, Todoish.Entries.Item 53 | 54 | many_to_many :users, Todoish.Entries.User do 55 | through(Todoish.Entries.UsersLists) 56 | source_attribute_on_join_resource(:list_id) 57 | destination_attribute_on_join_resource(:user_id) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /priv/resource_snapshots/repo/users/20230221171153.json: -------------------------------------------------------------------------------- 1 | { 2 | "attributes": [ 3 | { 4 | "allow_nil?": false, 5 | "default": "fragment(\"uuid_generate_v4()\")", 6 | "generated?": false, 7 | "primary_key?": true, 8 | "references": null, 9 | "size": null, 10 | "source": "id", 11 | "type": "uuid" 12 | }, 13 | { 14 | "allow_nil?": false, 15 | "default": "nil", 16 | "generated?": false, 17 | "primary_key?": false, 18 | "references": null, 19 | "size": null, 20 | "source": "email", 21 | "type": "citext" 22 | }, 23 | { 24 | "allow_nil?": false, 25 | "default": "nil", 26 | "generated?": false, 27 | "primary_key?": false, 28 | "references": null, 29 | "size": null, 30 | "source": "hashed_password", 31 | "type": "text" 32 | } 33 | ], 34 | "base_filter": null, 35 | "check_constraints": [], 36 | "custom_indexes": [], 37 | "custom_statements": [], 38 | "has_create_action": true, 39 | "hash": "6A8A2678703B3392E7E30F966692E8DD70EDCA3F5A14002792D5B1CDFC146F28", 40 | "identities": [ 41 | { 42 | "base_filter": null, 43 | "index_name": "users_unique_email_index", 44 | "keys": [ 45 | "email" 46 | ], 47 | "name": "unique_email" 48 | } 49 | ], 50 | "multitenancy": { 51 | "attribute": null, 52 | "global": null, 53 | "strategy": null 54 | }, 55 | "repo": "Elixir.Todoish.Repo", 56 | "schema": null, 57 | "table": "users" 58 | } -------------------------------------------------------------------------------- /priv/repo/migrations/20220830033004_add_lists_and_items.exs: -------------------------------------------------------------------------------- 1 | defmodule Todoish.Repo.Migrations.AddListsAndItems do 2 | @moduledoc """ 3 | Updates resources based on their most recent snapshots. 4 | 5 | This file was autogenerated with `mix ash_postgres.generate_migrations` 6 | """ 7 | 8 | use Ecto.Migration 9 | 10 | def up do 11 | create table(:lists, primary_key: false) do 12 | add :id, :uuid, null: false, primary_key: true 13 | add :title, :text, default: "A Todoish List" 14 | add :description, :text, default: "Add items to get started!" 15 | add :url_id, :text, null: false 16 | end 17 | 18 | create unique_index(:lists, [:url_id], name: "lists_unique_url_id_index") 19 | 20 | create table(:items, primary_key: false) do 21 | add :updated_at, :utc_datetime_usec, null: false, default: fragment("now()") 22 | add :inserted_at, :utc_datetime_usec, null: false, default: fragment("now()") 23 | add :id, :uuid, null: false, primary_key: true 24 | add :title, :text, null: false 25 | add :status, :text, null: false, default: "incompleted" 26 | 27 | add :list_id, 28 | references(:lists, 29 | column: :id, 30 | name: "items_list_id_fkey", 31 | type: :uuid, 32 | prefix: "public" 33 | ) 34 | end 35 | end 36 | 37 | def down do 38 | drop constraint(:items, "items_list_id_fkey") 39 | 40 | drop table(:items) 41 | 42 | drop_if_exists unique_index(:lists, [:url_id], name: "lists_unique_url_id_index") 43 | 44 | drop table(:lists) 45 | end 46 | end -------------------------------------------------------------------------------- /lib/todoish_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoishWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Phoenix Metrics 25 | summary("phoenix.endpoint.stop.duration", 26 | unit: {:native, :millisecond} 27 | ), 28 | summary("phoenix.router_dispatch.stop.duration", 29 | tags: [:route], 30 | unit: {:native, :millisecond} 31 | ), 32 | 33 | # VM Metrics 34 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 35 | summary("vm.total_run_queue_lengths.total"), 36 | summary("vm.total_run_queue_lengths.cpu"), 37 | summary("vm.total_run_queue_lengths.io") 38 | ] 39 | end 40 | 41 | defp periodic_measurements do 42 | [ 43 | # A module, function and arguments to be invoked periodically. 44 | # This function must call :telemetry.execute/3 and a metric must be added above. 45 | # {TodoishWeb, :count_users, []} 46 | ] 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/todoish_web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoishWeb.PageController do 2 | use TodoishWeb, :controller 3 | 4 | require Ash.Query 5 | 6 | def index(conn, _params) do 7 | render(conn, "index.html") 8 | end 9 | 10 | def new(conn, %{"new_list" => params}) do 11 | Todoish.Entries.List 12 | |> AshPhoenix.Form.for_create(:create, 13 | api: Todoish.Entries, 14 | transform_params: fn params, _ -> 15 | params = 16 | if params["title"] in ["", nil] do 17 | Map.put(params, "title", "A Todoish List") 18 | else 19 | params 20 | end 21 | 22 | params = 23 | if params["description"] in ["", nil] do 24 | Map.put(params, "description", "Add items to get started!") 25 | else 26 | params 27 | end 28 | 29 | Map.put(params, "url_id", Nanoid.generate()) 30 | end 31 | ) 32 | |> AshPhoenix.Form.validate(params) 33 | |> AshPhoenix.Form.submit() 34 | |> case do 35 | {:ok, result} -> 36 | if conn.assigns.current_user do 37 | user = conn.assigns.current_user 38 | 39 | Todoish.Entries.UsersLists 40 | |> Ash.Changeset.for_create(:new, %{list_id: result.id, user_id: user.id}) 41 | |> Todoish.Entries.create!() 42 | end 43 | 44 | redirect(conn, to: "/#{result.url_id}") 45 | 46 | {:error, form} -> 47 | IO.inspect(form) 48 | 49 | conn 50 | |> put_flash(:error, "Uh-oh! Something went wrong. Please try again!") 51 | |> render("index.html", form: form) 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/todoish_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoishWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :todoish 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: "_todoish_key", 10 | signing_salt: "j1RF2luB" 11 | ] 12 | 13 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 14 | 15 | # Serve at "/" the static files from "priv/static" directory. 16 | # 17 | # You should set gzip to true if you are running phx.digest 18 | # when deploying your static files in production. 19 | plug Plug.Static, 20 | at: "/", 21 | from: :todoish, 22 | gzip: false, 23 | only: ~w(assets fonts images favicon.ico robots.txt) 24 | 25 | # Code reloading can be explicitly enabled under the 26 | # :code_reloader configuration of your endpoint. 27 | if code_reloading? do 28 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 29 | plug Phoenix.LiveReloader 30 | plug Phoenix.CodeReloader 31 | end 32 | 33 | plug Phoenix.LiveDashboard.RequestLogger, 34 | param_key: "request_logger", 35 | cookie_key: "request_logger" 36 | 37 | plug Plug.RequestId 38 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 39 | 40 | plug Plug.Parsers, 41 | parsers: [:urlencoded, :multipart, :json], 42 | pass: ["*/*"], 43 | json_decoder: Phoenix.json_library() 44 | 45 | plug Plug.MethodOverride 46 | plug Plug.Head 47 | plug Plug.Session, @session_options 48 | plug TodoishWeb.Router 49 | end 50 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230221171152_add_user_and_token.exs: -------------------------------------------------------------------------------- 1 | defmodule Todoish.Repo.Migrations.AddUserAndToken do 2 | @moduledoc """ 3 | Updates resources based on their most recent snapshots. 4 | 5 | This file was autogenerated with `mix ash_postgres.generate_migrations` 6 | """ 7 | 8 | use Ecto.Migration 9 | 10 | def up do 11 | create table(:users, primary_key: false) do 12 | add :id, :uuid, null: false, default: fragment("uuid_generate_v4()"), primary_key: true 13 | add :email, :citext, null: false 14 | add :hashed_password, :text, null: false 15 | end 16 | 17 | create unique_index(:users, [:email], name: "users_unique_email_index") 18 | 19 | create table(:tokens, primary_key: false) do 20 | add :updated_at, :utc_datetime_usec, null: false, default: fragment("now()") 21 | add :created_at, :utc_datetime_usec, null: false, default: fragment("now()") 22 | add :extra_data, :map 23 | add :purpose, :text, null: false 24 | add :expires_at, :utc_datetime, null: false 25 | add :subject, :text, null: false 26 | add :jti, :text, null: false, primary_key: true 27 | end 28 | 29 | alter table(:lists) do 30 | modify :id, :uuid, default: fragment("uuid_generate_v4()") 31 | end 32 | 33 | alter table(:items) do 34 | modify :id, :uuid, default: fragment("uuid_generate_v4()") 35 | end 36 | end 37 | 38 | def down do 39 | alter table(:items) do 40 | modify :id, :uuid, default: nil 41 | end 42 | 43 | alter table(:lists) do 44 | modify :id, :uuid, default: nil 45 | end 46 | 47 | drop table(:tokens) 48 | 49 | drop_if_exists unique_index(:users, [:email], name: "users_unique_email_index") 50 | 51 | drop table(:users) 52 | end 53 | end -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | # Configures the endpoint 11 | config :todoish, TodoishWeb.Endpoint, 12 | url: [host: "localhost"], 13 | render_errors: [view: TodoishWeb.ErrorView, accepts: ~w(html json), layout: false], 14 | pubsub_server: Todoish.PubSub, 15 | live_view: [signing_salt: "1QozUebn"] 16 | 17 | # Configure esbuild (the version is required) 18 | config :esbuild, 19 | version: "0.14.29", 20 | default: [ 21 | args: 22 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), 23 | cd: Path.expand("../assets", __DIR__), 24 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 25 | ] 26 | 27 | # Configures Elixir's Logger 28 | config :logger, :console, 29 | format: "$time $metadata[$level] $message\n", 30 | metadata: [:request_id] 31 | 32 | # Use Jason for JSON parsing in Phoenix 33 | config :phoenix, :json_library, Jason 34 | 35 | config :tailwind, 36 | version: "3.1.8", 37 | default: [ 38 | args: ~w( 39 | --config=tailwind.config.js 40 | --input=css/app.css 41 | --output=../priv/static/assets/app.css 42 | ), 43 | cd: Path.expand("../assets", __DIR__) 44 | ] 45 | 46 | config :todoish, 47 | ash_apis: [Todoish.Entries] 48 | 49 | config :todoish, 50 | ecto_repos: [Todoish.Repo] 51 | 52 | # Import environment specific config. This must remain at the bottom 53 | # of this file so it overrides the configuration defined above. 54 | import_config "#{config_env()}.exs" 55 | -------------------------------------------------------------------------------- /priv/resource_snapshots/repo/lists/20220830033004.json: -------------------------------------------------------------------------------- 1 | { 2 | "attributes": [ 3 | { 4 | "allow_nil?": false, 5 | "default": "nil", 6 | "generated?": false, 7 | "primary_key?": true, 8 | "references": null, 9 | "size": null, 10 | "source": "id", 11 | "type": "uuid" 12 | }, 13 | { 14 | "allow_nil?": true, 15 | "default": "\"A Todoish List\"", 16 | "generated?": false, 17 | "primary_key?": false, 18 | "references": null, 19 | "size": null, 20 | "source": "title", 21 | "type": "text" 22 | }, 23 | { 24 | "allow_nil?": true, 25 | "default": "\"Add items to get started!\"", 26 | "generated?": false, 27 | "primary_key?": false, 28 | "references": null, 29 | "size": null, 30 | "source": "description", 31 | "type": "text" 32 | }, 33 | { 34 | "allow_nil?": false, 35 | "default": "nil", 36 | "generated?": false, 37 | "primary_key?": false, 38 | "references": null, 39 | "size": null, 40 | "source": "url_id", 41 | "type": "text" 42 | } 43 | ], 44 | "base_filter": null, 45 | "check_constraints": [], 46 | "custom_indexes": [], 47 | "custom_statements": [], 48 | "has_create_action": true, 49 | "hash": "2BB26F7A6FE064A0ACC098F29D63C4794642D20C5979D808094452A2B24D23C9", 50 | "identities": [ 51 | { 52 | "base_filter": null, 53 | "index_name": "lists_unique_url_id_index", 54 | "keys": [ 55 | "url_id" 56 | ], 57 | "name": "unique_url_id" 58 | } 59 | ], 60 | "multitenancy": { 61 | "attribute": null, 62 | "global": null, 63 | "strategy": null 64 | }, 65 | "repo": "Elixir.Todoish.Repo", 66 | "schema": null, 67 | "table": "lists" 68 | } -------------------------------------------------------------------------------- /lib/todoish_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoishWeb.Router do 2 | use TodoishWeb, :router 3 | use AshAuthentication.Phoenix.Router 4 | 5 | pipeline :browser do 6 | plug :accepts, ["html"] 7 | plug :fetch_session 8 | plug :fetch_live_flash 9 | plug :put_root_layout, {TodoishWeb.LayoutView, :root} 10 | plug :protect_from_forgery 11 | plug :put_secure_browser_headers 12 | plug :load_from_session 13 | end 14 | 15 | pipeline :api do 16 | plug :accepts, ["json"] 17 | plug :load_from_bearer 18 | end 19 | 20 | scope "/", TodoishWeb do 21 | pipe_through :browser 22 | 23 | get "/", PageController, :index 24 | post "/", PageController, :new 25 | 26 | get "/profile", ProfileController, :profile 27 | post "/profile/:list_id", ProfileController, :remove_list 28 | 29 | sign_in_route() 30 | sign_out_route AuthController 31 | auth_routes_for Todoish.Entries.User, to: AuthController 32 | reset_route [] 33 | 34 | live "/:url_id", Live.List 35 | end 36 | 37 | # Other scopes may use custom stacks. 38 | # scope "/api", TodoishWeb do 39 | # pipe_through :api 40 | # end 41 | 42 | # Enables LiveDashboard only for development 43 | # 44 | # If you want to use the LiveDashboard in production, you should put 45 | # it behind authentication and allow only admins to access it. 46 | # If your application does not have an admins-only section yet, 47 | # you can use Plug.BasicAuth to set up some basic authentication 48 | # as long as you are also using SSL (which you should anyway). 49 | if Mix.env() in [:dev, :test] do 50 | import Phoenix.LiveDashboard.Router 51 | 52 | scope "/" do 53 | pipe_through :browser 54 | 55 | live_dashboard "/dashboard", metrics: TodoishWeb.Telemetry 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/todoish/entries/resources/item.ex: -------------------------------------------------------------------------------- 1 | defmodule Todoish.Entries.Item do 2 | use Ash.Resource, data_layer: AshPostgres.DataLayer, notifiers: [Ash.Notifier.PubSub] 3 | 4 | postgres do 5 | table "items" 6 | repo Todoish.Repo 7 | end 8 | 9 | actions do 10 | defaults [:create, :read, :update, :destroy] 11 | 12 | create :new do 13 | accept [:title] 14 | 15 | argument :list_id, :uuid do 16 | allow_nil? false 17 | end 18 | 19 | change manage_relationship(:list_id, :list, type: :replace) 20 | end 21 | 22 | update :complete do 23 | accept [] 24 | 25 | change set_attribute(:status, :completed) 26 | end 27 | 28 | update :incomplete do 29 | accept [] 30 | 31 | change set_attribute(:status, :incompleted) 32 | end 33 | end 34 | 35 | attributes do 36 | uuid_primary_key :id 37 | 38 | timestamps() 39 | 40 | attribute :title, :string do 41 | allow_nil? false 42 | end 43 | 44 | attribute :status, :atom do 45 | constraints one_of: [:completed, :incompleted] 46 | 47 | default :incompleted 48 | 49 | allow_nil? false 50 | end 51 | end 52 | 53 | pub_sub do 54 | module TodoishWeb.Endpoint 55 | prefix "item" 56 | broadcast_type :phoenix_broadcast 57 | 58 | publish :new, ["list", :list_id], event: "item-added" 59 | publish :complete, ["list", :list_id], event: "item-updated" 60 | publish :incomplete, ["list", :list_id], event: "item-updated" 61 | publish :destroy, ["list", :list_id], event: "item-deleted" 62 | 63 | publish_all :create, ["created"] 64 | 65 | publish :react, 66 | ["reaction", :id], 67 | event: "reaction" 68 | end 69 | 70 | relationships do 71 | belongs_to :list, Todoish.Entries.List 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // See the Tailwind configuration guide for advanced usage 2 | // https://tailwindcss.com/docs/configuration 3 | 4 | let plugin = require("tailwindcss/plugin"); 5 | 6 | module.exports = { 7 | content: [ 8 | "./js/**/*.js", 9 | "../lib/*_web.ex", 10 | "../lib/*_web/**/*.*ex", 11 | "../deps/ash_authentication_phoenix/**/*.ex", 12 | ], 13 | theme: { 14 | extend: { 15 | colors: { 16 | base: { 17 | 50: "#F5F7FA", 18 | 100: "#E4E7EB", 19 | 200: "#CBD2D9", 20 | 300: "#9AA5B1", 21 | 400: "#7B8794", 22 | 500: "#616E7C", 23 | 600: "#52606D", 24 | 700: "3E4C59", 25 | 800: "#323F4B", 26 | 900: "#1F2933", 27 | }, 28 | primary: { 29 | 50: "#E3F9E5", 30 | 100: "#C1F2C7", 31 | 200: "#91E697", 32 | 300: "#51CA58", 33 | 400: "#31B237", 34 | 500: "#18981D", 35 | 600: "#0F8613", 36 | 700: "#0E7817", 37 | 800: "#07600E", 38 | 900: "#014807", 39 | }, 40 | }, 41 | }, 42 | }, 43 | plugins: [ 44 | require("@tailwindcss/forms"), 45 | plugin(({ addVariant }) => 46 | addVariant("phx-no-feedback", ["&.phx-no-feedback", ".phx-no-feedback &"]) 47 | ), 48 | plugin(({ addVariant }) => 49 | addVariant("phx-click-loading", [ 50 | "&.phx-click-loading", 51 | ".phx-click-loading &", 52 | ]) 53 | ), 54 | plugin(({ addVariant }) => 55 | addVariant("phx-submit-loading", [ 56 | "&.phx-submit-loading", 57 | ".phx-submit-loading &", 58 | ]) 59 | ), 60 | plugin(({ addVariant }) => 61 | addVariant("phx-change-loading", [ 62 | "&.phx-change-loading", 63 | ".phx-change-loading &", 64 | ]) 65 | ), 66 | ], 67 | }; 68 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For production, don't forget to configure the url host 4 | # to something meaningful, Phoenix uses this information 5 | # when generating URLs. 6 | # 7 | # Note we also include the path to a cache manifest 8 | # containing the digested version of static files. This 9 | # manifest is generated by the `mix phx.digest` task, 10 | # which you should run after static files are built and 11 | # before starting your production server. 12 | config :todoish, TodoishWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" 13 | 14 | # Do not print debug messages in production 15 | config :logger, level: :info 16 | 17 | # ## SSL Support 18 | # 19 | # To get SSL working, you will need to add the `https` key 20 | # to the previous section and set your `:url` port to 443: 21 | # 22 | # config :todoish, TodoishWeb.Endpoint, 23 | # ..., 24 | # url: [host: "example.com", port: 443], 25 | # https: [ 26 | # ..., 27 | # port: 443, 28 | # cipher_suite: :strong, 29 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 30 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 31 | # ] 32 | # 33 | # The `cipher_suite` is set to `:strong` to support only the 34 | # latest and more secure SSL ciphers. This means old browsers 35 | # and clients may not be supported. You can set it to 36 | # `:compatible` for wider support. 37 | # 38 | # `:keyfile` and `:certfile` expect an absolute path to the key 39 | # and cert in disk or a relative path inside priv, for example 40 | # "priv/ssl/server.key". For all supported SSL configuration 41 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 42 | # 43 | # We also recommend setting `force_ssl` in your endpoint, ensuring 44 | # no data is ever sent via http, always redirecting to https: 45 | # 46 | # config :todoish, TodoishWeb.Endpoint, 47 | # force_ssl: [hsts: true] 48 | # 49 | # Check `Plug.SSL` for all available options in `force_ssl`. 50 | -------------------------------------------------------------------------------- /lib/todoish/entries_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Todoish.EntriesServer do 2 | use GenServer 3 | 4 | require Ash.Query 5 | 6 | # 7 days 7 | @keep_alive 604_800_000 8 | 9 | def start_link(_state) do 10 | GenServer.start_link(__MODULE__, %{}) 11 | end 12 | 13 | @impl true 14 | def init(_state) do 15 | TodoishWeb.Endpoint.subscribe("list:created") 16 | 17 | lists = 18 | Todoish.Entries.List 19 | |> Ash.Query.select([:id, :inserted_at]) 20 | |> Todoish.Entries.read!() 21 | 22 | now = DateTime.utc_now() |> DateTime.to_unix(:millisecond) 23 | 24 | for list <- lists do 25 | inserted_at = DateTime.to_unix(list.inserted_at, :millisecond) 26 | delete_at = inserted_at + @keep_alive 27 | 28 | if now > delete_at do 29 | spawn(fn -> delete_list_and_items(list) end) 30 | else 31 | spawn(fn -> delete_list_and_items(list, delete_at - now) end) 32 | end 33 | end 34 | 35 | {:ok, %{}} 36 | end 37 | 38 | @impl true 39 | def handle_info(%{event: "new-list", payload: payload}, state) do 40 | spawn(fn -> delete_list_and_items(payload.data, @keep_alive) end) 41 | 42 | {:noreply, state} 43 | end 44 | 45 | defp delete_list_and_items(list, delay \\ 0) do 46 | # :pass 47 | :timer.sleep(delay) 48 | 49 | list = 50 | Todoish.Entries.List 51 | |> Ash.Query.filter(id == ^list.id) 52 | |> Ash.Query.limit(1) 53 | |> Ash.Query.select([]) 54 | |> Ash.Query.load(items: Ash.Query.select(Todoish.Entries.Item, [:id])) 55 | |> Todoish.Entries.read_one!() 56 | 57 | # user_lists = 58 | # Todoish.Entries.List 59 | # |> Ash.Query.filter(list_id == ^list.id) 60 | # |> Ash.Query.select([]) 61 | # |> Todoish.Entries.read!() 62 | 63 | for item <- list.items do 64 | item 65 | |> Ash.Changeset.for_destroy(:destroy) 66 | |> Todoish.Entries.destroy!() 67 | end 68 | 69 | list 70 | |> Ash.Changeset.for_destroy(:destroy) 71 | |> Todoish.Entries.destroy!() 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /priv/resource_snapshots/repo/users_lists/20230221195635.json: -------------------------------------------------------------------------------- 1 | { 2 | "attributes": [ 3 | { 4 | "allow_nil?": false, 5 | "default": "fragment(\"uuid_generate_v4()\")", 6 | "generated?": false, 7 | "primary_key?": true, 8 | "references": null, 9 | "size": null, 10 | "source": "id", 11 | "type": "uuid" 12 | }, 13 | { 14 | "allow_nil?": true, 15 | "default": "nil", 16 | "generated?": false, 17 | "primary_key?": false, 18 | "references": { 19 | "destination_attribute": "id", 20 | "destination_attribute_default": null, 21 | "destination_attribute_generated": null, 22 | "multitenancy": { 23 | "attribute": null, 24 | "global": null, 25 | "strategy": null 26 | }, 27 | "name": "users_lists_list_id_fkey", 28 | "on_delete": null, 29 | "on_update": null, 30 | "schema": "public", 31 | "table": "lists" 32 | }, 33 | "size": null, 34 | "source": "list_id", 35 | "type": "uuid" 36 | }, 37 | { 38 | "allow_nil?": true, 39 | "default": "nil", 40 | "generated?": false, 41 | "primary_key?": false, 42 | "references": { 43 | "destination_attribute": "id", 44 | "destination_attribute_default": null, 45 | "destination_attribute_generated": null, 46 | "multitenancy": { 47 | "attribute": null, 48 | "global": null, 49 | "strategy": null 50 | }, 51 | "name": "users_lists_user_id_fkey", 52 | "on_delete": null, 53 | "on_update": null, 54 | "schema": "public", 55 | "table": "users" 56 | }, 57 | "size": null, 58 | "source": "user_id", 59 | "type": "uuid" 60 | } 61 | ], 62 | "base_filter": null, 63 | "check_constraints": [], 64 | "custom_indexes": [], 65 | "custom_statements": [], 66 | "has_create_action": false, 67 | "hash": "1E9F1D642A6561C2AC5BE60C446F94CA7120850E4ADEAC7A73A5FA06EE61AF41", 68 | "identities": [], 69 | "multitenancy": { 70 | "attribute": null, 71 | "global": null, 72 | "strategy": null 73 | }, 74 | "repo": "Elixir.Todoish.Repo", 75 | "schema": null, 76 | "table": "users_lists" 77 | } -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Todoish.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :todoish, 7 | version: "0.1.0", 8 | elixir: "~> 1.12", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | compilers: Mix.compilers(), 11 | start_permanent: Mix.env() == :prod, 12 | aliases: aliases(), 13 | deps: deps() 14 | ] 15 | end 16 | 17 | # Configuration for the OTP application. 18 | # 19 | # Type `mix help compile.app` for more information. 20 | def application do 21 | [ 22 | mod: {Todoish.Application, []}, 23 | extra_applications: [:logger, :runtime_tools] 24 | ] 25 | end 26 | 27 | # Specifies which paths to compile per environment. 28 | defp elixirc_paths(:test), do: ["lib", "test/support"] 29 | defp elixirc_paths(_), do: ["lib"] 30 | 31 | # Specifies your project dependencies. 32 | # 33 | # Type `mix help deps` for examples and options. 34 | defp deps do 35 | [ 36 | {:phoenix, "~> 1.6.11"}, 37 | {:phoenix_html, "~> 3.0"}, 38 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 39 | {:phoenix_live_view, "~> 0.18.3"}, 40 | {:floki, ">= 0.30.0", only: :test}, 41 | {:phoenix_live_dashboard, "~> 0.7.2"}, 42 | {:esbuild, "~> 0.4", runtime: Mix.env() == :dev}, 43 | {:telemetry_metrics, "~> 0.6"}, 44 | {:telemetry_poller, "~> 1.0"}, 45 | {:jason, "~> 1.2"}, 46 | {:plug_cowboy, "~> 2.5"}, 47 | {:tailwind, "~> 0.1", runtime: Mix.env() == :dev}, 48 | {:ash, "~> 2.6.8"}, 49 | {:ash_authentication, "~> 3.9.3"}, 50 | {:ash_authentication_phoenix, "~> 1.5"}, 51 | {:ash_postgres, "~> 1.3.12"}, 52 | {:ash_phoenix, "~> 1.2"}, 53 | {:elixir_sense, github: "elixir-lsp/elixir_sense", only: [:dev, :test]}, 54 | {:nanoid, "~> 2.0"} 55 | ] 56 | end 57 | 58 | # Aliases are shortcuts or tasks specific to the current project. 59 | # For example, to install project dependencies and perform other setup tasks, run: 60 | # 61 | # $ mix setup 62 | # 63 | # See the documentation for `Mix` for more info on aliases. 64 | defp aliases do 65 | [ 66 | setup: ["deps.get"], 67 | "assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"] 68 | ] 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /priv/resource_snapshots/repo/lists/20220830233010.json: -------------------------------------------------------------------------------- 1 | { 2 | "attributes": [ 3 | { 4 | "allow_nil?": false, 5 | "default": "fragment(\"now()\")", 6 | "generated?": false, 7 | "primary_key?": false, 8 | "references": null, 9 | "size": null, 10 | "source": "updated_at", 11 | "type": "utc_datetime_usec" 12 | }, 13 | { 14 | "allow_nil?": false, 15 | "default": "fragment(\"now()\")", 16 | "generated?": false, 17 | "primary_key?": false, 18 | "references": null, 19 | "size": null, 20 | "source": "inserted_at", 21 | "type": "utc_datetime_usec" 22 | }, 23 | { 24 | "allow_nil?": false, 25 | "default": "nil", 26 | "generated?": false, 27 | "primary_key?": true, 28 | "references": null, 29 | "size": null, 30 | "source": "id", 31 | "type": "uuid" 32 | }, 33 | { 34 | "allow_nil?": true, 35 | "default": "\"A Todoish List\"", 36 | "generated?": false, 37 | "primary_key?": false, 38 | "references": null, 39 | "size": null, 40 | "source": "title", 41 | "type": "text" 42 | }, 43 | { 44 | "allow_nil?": true, 45 | "default": "\"Add items to get started!\"", 46 | "generated?": false, 47 | "primary_key?": false, 48 | "references": null, 49 | "size": null, 50 | "source": "description", 51 | "type": "text" 52 | }, 53 | { 54 | "allow_nil?": false, 55 | "default": "nil", 56 | "generated?": false, 57 | "primary_key?": false, 58 | "references": null, 59 | "size": null, 60 | "source": "url_id", 61 | "type": "text" 62 | } 63 | ], 64 | "base_filter": null, 65 | "check_constraints": [], 66 | "custom_indexes": [], 67 | "custom_statements": [], 68 | "has_create_action": true, 69 | "hash": "59E0026DE7918CB9F4EC6ADEE90236D8FE13CFBB70EDEA92CDD8B546F0EEA4AD", 70 | "identities": [ 71 | { 72 | "base_filter": null, 73 | "index_name": "lists_unique_url_id_index", 74 | "keys": [ 75 | "url_id" 76 | ], 77 | "name": "unique_url_id" 78 | } 79 | ], 80 | "multitenancy": { 81 | "attribute": null, 82 | "global": null, 83 | "strategy": null 84 | }, 85 | "repo": "Elixir.Todoish.Repo", 86 | "schema": null, 87 | "table": "lists" 88 | } -------------------------------------------------------------------------------- /priv/resource_snapshots/repo/lists/20230221171153.json: -------------------------------------------------------------------------------- 1 | { 2 | "attributes": [ 3 | { 4 | "allow_nil?": false, 5 | "default": "fragment(\"uuid_generate_v4()\")", 6 | "generated?": false, 7 | "primary_key?": true, 8 | "references": null, 9 | "size": null, 10 | "source": "id", 11 | "type": "uuid" 12 | }, 13 | { 14 | "allow_nil?": false, 15 | "default": "fragment(\"now()\")", 16 | "generated?": false, 17 | "primary_key?": false, 18 | "references": null, 19 | "size": null, 20 | "source": "inserted_at", 21 | "type": "utc_datetime_usec" 22 | }, 23 | { 24 | "allow_nil?": false, 25 | "default": "fragment(\"now()\")", 26 | "generated?": false, 27 | "primary_key?": false, 28 | "references": null, 29 | "size": null, 30 | "source": "updated_at", 31 | "type": "utc_datetime_usec" 32 | }, 33 | { 34 | "allow_nil?": true, 35 | "default": "\"A Todoish List\"", 36 | "generated?": false, 37 | "primary_key?": false, 38 | "references": null, 39 | "size": null, 40 | "source": "title", 41 | "type": "text" 42 | }, 43 | { 44 | "allow_nil?": true, 45 | "default": "\"Add items to get started!\"", 46 | "generated?": false, 47 | "primary_key?": false, 48 | "references": null, 49 | "size": null, 50 | "source": "description", 51 | "type": "text" 52 | }, 53 | { 54 | "allow_nil?": false, 55 | "default": "nil", 56 | "generated?": false, 57 | "primary_key?": false, 58 | "references": null, 59 | "size": null, 60 | "source": "url_id", 61 | "type": "text" 62 | } 63 | ], 64 | "base_filter": null, 65 | "check_constraints": [], 66 | "custom_indexes": [], 67 | "custom_statements": [], 68 | "has_create_action": true, 69 | "hash": "FC42A8F0956D50F8D13BEA3EB3C0903945F45B2B62ECF4B89FA378814922A3A8", 70 | "identities": [ 71 | { 72 | "base_filter": null, 73 | "index_name": "lists_unique_url_id_index", 74 | "keys": [ 75 | "url_id" 76 | ], 77 | "name": "unique_url_id" 78 | } 79 | ], 80 | "multitenancy": { 81 | "attribute": null, 82 | "global": null, 83 | "strategy": null 84 | }, 85 | "repo": "Elixir.Todoish.Repo", 86 | "schema": null, 87 | "table": "lists" 88 | } -------------------------------------------------------------------------------- /priv/resource_snapshots/repo/tokens/20230221171153.json: -------------------------------------------------------------------------------- 1 | { 2 | "attributes": [ 3 | { 4 | "allow_nil?": false, 5 | "default": "fragment(\"now()\")", 6 | "generated?": false, 7 | "primary_key?": false, 8 | "references": null, 9 | "size": null, 10 | "source": "updated_at", 11 | "type": "utc_datetime_usec" 12 | }, 13 | { 14 | "allow_nil?": false, 15 | "default": "fragment(\"now()\")", 16 | "generated?": false, 17 | "primary_key?": false, 18 | "references": null, 19 | "size": null, 20 | "source": "created_at", 21 | "type": "utc_datetime_usec" 22 | }, 23 | { 24 | "allow_nil?": true, 25 | "default": "nil", 26 | "generated?": false, 27 | "primary_key?": false, 28 | "references": null, 29 | "size": null, 30 | "source": "extra_data", 31 | "type": "map" 32 | }, 33 | { 34 | "allow_nil?": false, 35 | "default": "nil", 36 | "generated?": false, 37 | "primary_key?": false, 38 | "references": null, 39 | "size": null, 40 | "source": "purpose", 41 | "type": "text" 42 | }, 43 | { 44 | "allow_nil?": false, 45 | "default": "nil", 46 | "generated?": false, 47 | "primary_key?": false, 48 | "references": null, 49 | "size": null, 50 | "source": "expires_at", 51 | "type": "utc_datetime" 52 | }, 53 | { 54 | "allow_nil?": false, 55 | "default": "nil", 56 | "generated?": false, 57 | "primary_key?": false, 58 | "references": null, 59 | "size": null, 60 | "source": "subject", 61 | "type": "text" 62 | }, 63 | { 64 | "allow_nil?": false, 65 | "default": "nil", 66 | "generated?": false, 67 | "primary_key?": true, 68 | "references": null, 69 | "size": null, 70 | "source": "jti", 71 | "type": "text" 72 | } 73 | ], 74 | "base_filter": null, 75 | "check_constraints": [], 76 | "custom_indexes": [], 77 | "custom_statements": [], 78 | "has_create_action": true, 79 | "hash": "9BE3548231ACB8F11E7292F133CD8C349AE8D5B97BBEA718597A1AACAC8AF1B2", 80 | "identities": [], 81 | "multitenancy": { 82 | "attribute": null, 83 | "global": null, 84 | "strategy": null 85 | }, 86 | "repo": "Elixir.Todoish.Repo", 87 | "schema": null, 88 | "table": "tokens" 89 | } -------------------------------------------------------------------------------- /lib/todoish_web/templates/profile/profile.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | <%= 6 | @current_user.email 7 | |> Ash.CiString.value() 8 | |> String.first() 9 | %> 10 |
11 |
<%= @current_user.email %>
12 | Sign out 13 |
14 |
15 |

Saved Lists

16 |
17 | <%= for list <- @lists do %> 18 |
19 |
20 | <%= list.title %> 21 |
22 | <%= if String.length(list.description) < 24 do %> 23 | <%= list.description %> 24 | <% else %> 25 | <%= String.slice(list.description, 0, 24) <> "..." %> 26 | <% end %> 27 |
28 |
29 | Deleting in 30 | 31 | ~<%= 32 | seven_days = 604_800_000 33 | one_day = 86_400_000 34 | now = DateTime.utc_now() |> DateTime.to_unix() 35 | inserted_date = list.inserted_at 36 | inserted_unix = DateTime.to_unix(inserted_date) 37 | now_unix = DateTime.utc_now() |> DateTime.to_unix() 38 | Float.round((seven_days - (now_unix - inserted_unix)) / one_day) |> trunc() 39 | %> days 40 | 41 |
42 |
43 | <%= form_for @conn, Routes.profile_path(@conn, :remove_list, list.id), [], fn f-> %> 44 | 45 | <% end %> 46 |
47 | <% end %> 48 |
49 |
50 |
51 |
52 | -------------------------------------------------------------------------------- /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/todoish 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 :todoish, TodoishWeb.Endpoint, server: true 21 | end 22 | 23 | if config_env() == :prod do 24 | # The secret key base is used to sign/encrypt cookies and other secrets. 25 | # A default value is used in config/dev.exs and config/test.exs but you 26 | # want to use a different value for prod and you most likely don't want 27 | # to check this value into version control, so we use an environment 28 | # variable instead. 29 | secret_key_base = 30 | System.get_env("SECRET_KEY_BASE") || 31 | raise """ 32 | environment variable SECRET_KEY_BASE is missing. 33 | You can generate one by calling: mix phx.gen.secret 34 | """ 35 | 36 | host = System.get_env("PHX_HOST") || "todoi.sh" 37 | port = String.to_integer(System.get_env("PORT") || "4000") 38 | 39 | config :todoish, TodoishWeb.Endpoint, 40 | url: [host: host, port: 443, scheme: "https"], 41 | http: [ 42 | # Enable IPv6 and bind on all interfaces. 43 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 44 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html 45 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 46 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 47 | port: port 48 | ], 49 | secret_key_base: secret_key_base 50 | 51 | database_url = 52 | System.get_env("DATABASE_URL") || 53 | raise """ 54 | environment variable DATABASE_URL is missing. 55 | For example: ecto://USER:PASS@HOST/DATABASE 56 | """ 57 | 58 | config :todoish, Todoish.Repo, 59 | ssl: false, 60 | url: database_url, 61 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), 62 | socket_options: [:inet6] 63 | end 64 | -------------------------------------------------------------------------------- /priv/resource_snapshots/repo/items/20220830033004.json: -------------------------------------------------------------------------------- 1 | { 2 | "attributes": [ 3 | { 4 | "allow_nil?": false, 5 | "default": "fragment(\"now()\")", 6 | "generated?": false, 7 | "primary_key?": false, 8 | "references": null, 9 | "size": null, 10 | "source": "updated_at", 11 | "type": "utc_datetime_usec" 12 | }, 13 | { 14 | "allow_nil?": false, 15 | "default": "fragment(\"now()\")", 16 | "generated?": false, 17 | "primary_key?": false, 18 | "references": null, 19 | "size": null, 20 | "source": "inserted_at", 21 | "type": "utc_datetime_usec" 22 | }, 23 | { 24 | "allow_nil?": false, 25 | "default": "nil", 26 | "generated?": false, 27 | "primary_key?": true, 28 | "references": null, 29 | "size": null, 30 | "source": "id", 31 | "type": "uuid" 32 | }, 33 | { 34 | "allow_nil?": false, 35 | "default": "nil", 36 | "generated?": false, 37 | "primary_key?": false, 38 | "references": null, 39 | "size": null, 40 | "source": "title", 41 | "type": "text" 42 | }, 43 | { 44 | "allow_nil?": false, 45 | "default": "\"incompleted\"", 46 | "generated?": false, 47 | "primary_key?": false, 48 | "references": null, 49 | "size": null, 50 | "source": "status", 51 | "type": "text" 52 | }, 53 | { 54 | "allow_nil?": true, 55 | "default": "nil", 56 | "generated?": false, 57 | "primary_key?": false, 58 | "references": { 59 | "destination_attribute": "id", 60 | "destination_attribute_default": null, 61 | "destination_attribute_generated": null, 62 | "multitenancy": { 63 | "attribute": null, 64 | "global": null, 65 | "strategy": null 66 | }, 67 | "name": "items_list_id_fkey", 68 | "on_delete": null, 69 | "on_update": null, 70 | "schema": "public", 71 | "table": "lists" 72 | }, 73 | "size": null, 74 | "source": "list_id", 75 | "type": "uuid" 76 | } 77 | ], 78 | "base_filter": null, 79 | "check_constraints": [], 80 | "custom_indexes": [], 81 | "custom_statements": [], 82 | "has_create_action": true, 83 | "hash": "65943C35669921908F7B34FFFA2358D3D4AD0B93669F08D84E40783E73690FBB", 84 | "identities": [], 85 | "multitenancy": { 86 | "attribute": null, 87 | "global": null, 88 | "strategy": null 89 | }, 90 | "repo": "Elixir.Todoish.Repo", 91 | "schema": null, 92 | "table": "items" 93 | } -------------------------------------------------------------------------------- /priv/resource_snapshots/repo/items/20230221171152.json: -------------------------------------------------------------------------------- 1 | { 2 | "attributes": [ 3 | { 4 | "allow_nil?": false, 5 | "default": "fragment(\"uuid_generate_v4()\")", 6 | "generated?": false, 7 | "primary_key?": true, 8 | "references": null, 9 | "size": null, 10 | "source": "id", 11 | "type": "uuid" 12 | }, 13 | { 14 | "allow_nil?": false, 15 | "default": "fragment(\"now()\")", 16 | "generated?": false, 17 | "primary_key?": false, 18 | "references": null, 19 | "size": null, 20 | "source": "inserted_at", 21 | "type": "utc_datetime_usec" 22 | }, 23 | { 24 | "allow_nil?": false, 25 | "default": "fragment(\"now()\")", 26 | "generated?": false, 27 | "primary_key?": false, 28 | "references": null, 29 | "size": null, 30 | "source": "updated_at", 31 | "type": "utc_datetime_usec" 32 | }, 33 | { 34 | "allow_nil?": false, 35 | "default": "nil", 36 | "generated?": false, 37 | "primary_key?": false, 38 | "references": null, 39 | "size": null, 40 | "source": "title", 41 | "type": "text" 42 | }, 43 | { 44 | "allow_nil?": false, 45 | "default": "\"incompleted\"", 46 | "generated?": false, 47 | "primary_key?": false, 48 | "references": null, 49 | "size": null, 50 | "source": "status", 51 | "type": "text" 52 | }, 53 | { 54 | "allow_nil?": true, 55 | "default": "nil", 56 | "generated?": false, 57 | "primary_key?": false, 58 | "references": { 59 | "destination_attribute": "id", 60 | "destination_attribute_default": null, 61 | "destination_attribute_generated": null, 62 | "multitenancy": { 63 | "attribute": null, 64 | "global": null, 65 | "strategy": null 66 | }, 67 | "name": "items_list_id_fkey", 68 | "on_delete": null, 69 | "on_update": null, 70 | "schema": "public", 71 | "table": "lists" 72 | }, 73 | "size": null, 74 | "source": "list_id", 75 | "type": "uuid" 76 | } 77 | ], 78 | "base_filter": null, 79 | "check_constraints": [], 80 | "custom_indexes": [], 81 | "custom_statements": [], 82 | "has_create_action": true, 83 | "hash": "136B3150310B286234FCF2DEA76E6C62825D3BFFE501708C89E994DF128085AD", 84 | "identities": [], 85 | "multitenancy": { 86 | "attribute": null, 87 | "global": null, 88 | "strategy": null 89 | }, 90 | "repo": "Elixir.Todoish.Repo", 91 | "schema": null, 92 | "table": "items" 93 | } -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we use it 8 | # with esbuild to bundle .js and .css sources. 9 | config :todoish, TodoishWeb.Endpoint, 10 | # Binding to loopback ipv4 address prevents access from other machines. 11 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 12 | http: [ip: {127, 0, 0, 1}, port: 4000], 13 | check_origin: false, 14 | code_reloader: true, 15 | debug_errors: true, 16 | secret_key_base: "x/wIOS0SzlXxP1cAkX4OeVy3iar8MDyLL1/yXcQ4ngnHybKVVAelLGpdpANbe78v", 17 | watchers: [ 18 | # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args) 19 | esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, 20 | tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} 21 | ] 22 | 23 | # ## SSL Support 24 | # 25 | # In order to use HTTPS in development, a self-signed 26 | # certificate can be generated by running the following 27 | # Mix task: 28 | # 29 | # mix phx.gen.cert 30 | # 31 | # Note that this task requires Erlang/OTP 20 or later. 32 | # Run `mix help phx.gen.cert` for more information. 33 | # 34 | # The `http:` config above can be replaced with: 35 | # 36 | # https: [ 37 | # port: 4001, 38 | # cipher_suite: :strong, 39 | # keyfile: "priv/cert/selfsigned_key.pem", 40 | # certfile: "priv/cert/selfsigned.pem" 41 | # ], 42 | # 43 | # If desired, both `http:` and `https:` keys can be 44 | # configured to run both http and https servers on 45 | # different ports. 46 | 47 | # Watch static and templates for browser reloading. 48 | config :todoish, TodoishWeb.Endpoint, 49 | live_reload: [ 50 | patterns: [ 51 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 52 | ~r"lib/todoish_web/(live|views)/.*(ex)$", 53 | ~r"lib/todoish_web/templates/.*(eex)$" 54 | ] 55 | ] 56 | 57 | config :todoish, Todoish.Repo, 58 | username: "postgres", 59 | password: "postgres", 60 | hostname: "localhost", 61 | database: "todoish_dev", 62 | port: 5432, 63 | show_sensitive_data_on_connection_error: true, 64 | pool_size: 10 65 | 66 | # Do not include metadata nor timestamps in development logs 67 | config :logger, :console, format: "[$level] $message\n" 68 | 69 | # Set a higher stacktrace during development. Avoid configuring such 70 | # in production as building large stacktraces may be expensive. 71 | config :phoenix, :stacktrace_depth, 20 72 | 73 | # Initialize plugs at runtime for faster development compilation 74 | config :phoenix, :plug_init_mode, :runtime 75 | -------------------------------------------------------------------------------- /lib/todoish_web/templates/layout/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= live_title_tag assigns[:page_title] || "Todoish", suffix: " · A sharable list!" %> 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 | 18 |
Todoish
19 |
20 |
21 |
22 | <%= if @current_user do %> 23 | Sign out 24 | 25 | <%= 26 | @current_user.email 27 | |> Ash.CiString.value() 28 | |> String.first() 29 | %> 30 | 31 | <% else %> 32 | Sign in 33 | <% end %> 34 |
35 |
36 |
37 | <%= @inner_content %> 38 |
39 |
40 |
41 | Built with  42 | ⚗️ 43 | 🐦 44 | 🔥 45 |
46 |
47 |
48 | Made by  49 | 🍞 50 |
51 |
52 |
53 | 54 | 55 | -------------------------------------------------------------------------------- /lib/todoish_web.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoishWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, views, channels and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use TodoishWeb, :controller 9 | use TodoishWeb, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define any helper function in modules 17 | and import those modules here. 18 | """ 19 | 20 | def controller do 21 | quote do 22 | use Phoenix.Controller, namespace: TodoishWeb 23 | 24 | import Plug.Conn 25 | alias TodoishWeb.Router.Helpers, as: Routes 26 | end 27 | end 28 | 29 | def view do 30 | quote do 31 | use Phoenix.View, 32 | root: "lib/todoish_web/templates", 33 | namespace: TodoishWeb 34 | 35 | # Import convenience functions from controllers 36 | import Phoenix.Controller, 37 | only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] 38 | 39 | # Include shared imports and aliases for views 40 | unquote(view_helpers()) 41 | end 42 | end 43 | 44 | def live_view do 45 | quote do 46 | use Phoenix.LiveView, 47 | layout: {TodoishWeb.LayoutView, "live.html"} 48 | 49 | unquote(view_helpers()) 50 | end 51 | end 52 | 53 | def live_component do 54 | quote do 55 | use Phoenix.LiveComponent 56 | 57 | unquote(view_helpers()) 58 | end 59 | end 60 | 61 | def component do 62 | quote do 63 | use Phoenix.Component 64 | 65 | unquote(view_helpers()) 66 | end 67 | end 68 | 69 | def router do 70 | quote do 71 | use Phoenix.Router 72 | 73 | import Plug.Conn 74 | import Phoenix.Controller 75 | import Phoenix.LiveView.Router 76 | end 77 | end 78 | 79 | def channel do 80 | quote do 81 | use Phoenix.Channel 82 | end 83 | end 84 | 85 | defp view_helpers do 86 | quote do 87 | # Use all HTML functionality (forms, tags, etc) 88 | use Phoenix.HTML 89 | 90 | # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) 91 | import Phoenix.LiveView.Helpers 92 | 93 | # Import basic rendering functionality (render, render_layout, etc) 94 | import Phoenix.View 95 | 96 | import TodoishWeb.ErrorHelpers 97 | alias TodoishWeb.Router.Helpers, as: Routes 98 | end 99 | end 100 | 101 | @doc """ 102 | When used, dispatch to the appropriate controller/view/etc. 103 | """ 104 | defmacro __using__(which) when is_atom(which) do 105 | apply(__MODULE__, which, []) 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian instead of 2 | # Alpine to avoid DNS resolution issues in production. 3 | # 4 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu 5 | # https://hub.docker.com/_/ubuntu?tab=tags 6 | # 7 | # 8 | # This file is based on these images: 9 | # 10 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image 11 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20210902-slim - for the release image 12 | # - https://pkgs.org/ - resource for finding needed packages 13 | # - Ex: hexpm/elixir:1.13.4-erlang-25.0.4-debian-bullseye-20210902-slim 14 | # 15 | ARG ELIXIR_VERSION=1.13.4 16 | ARG OTP_VERSION=25.0.4 17 | ARG DEBIAN_VERSION=bullseye-20220801-slim 18 | 19 | ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" 20 | ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" 21 | 22 | FROM ${BUILDER_IMAGE} as builder 23 | 24 | # install build dependencies 25 | RUN apt-get update -y && apt-get install -y build-essential git \ 26 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 27 | 28 | # prepare build dir 29 | WORKDIR /app 30 | 31 | # install hex + rebar 32 | RUN mix local.hex --force && \ 33 | mix local.rebar --force 34 | 35 | # set build ENV 36 | ENV MIX_ENV="prod" 37 | 38 | # install mix dependencies 39 | COPY mix.exs mix.lock ./ 40 | RUN mix deps.get --only $MIX_ENV 41 | RUN mkdir config 42 | 43 | # copy compile-time config files before we compile dependencies 44 | # to ensure any relevant config change will trigger the dependencies 45 | # to be re-compiled. 46 | COPY config/config.exs config/${MIX_ENV}.exs config/ 47 | RUN mix deps.compile 48 | 49 | COPY priv priv 50 | 51 | COPY lib lib 52 | 53 | COPY assets assets 54 | 55 | # compile assets 56 | RUN mix assets.deploy 57 | 58 | # Compile the release 59 | RUN mix compile 60 | 61 | # Changes to config/runtime.exs don't require recompiling the code 62 | COPY config/runtime.exs config/ 63 | 64 | COPY rel rel 65 | RUN mix release 66 | 67 | # start a new build stage so that the final image will only contain 68 | # the compiled release and other runtime necessities 69 | FROM ${RUNNER_IMAGE} 70 | 71 | RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales \ 72 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 73 | 74 | # Set the locale 75 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen 76 | 77 | ENV LANG en_US.UTF-8 78 | ENV LANGUAGE en_US:en 79 | ENV LC_ALL en_US.UTF-8 80 | 81 | WORKDIR "/app" 82 | RUN chown nobody /app 83 | 84 | # set runner ENV 85 | ENV MIX_ENV="prod" 86 | 87 | # Only copy the final release from the build stage 88 | COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/todoish ./ 89 | 90 | USER nobody 91 | 92 | CMD ["/app/bin/server"] 93 | # Appended by flyctl 94 | ENV ECTO_IPV6 true 95 | ENV ERL_AFLAGS "-proto_dist inet6_tcp" 96 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@100;200;300;400;500;600;700;800;900&display=swap'); 2 | 3 | @import "tailwindcss/base"; 4 | @import "tailwindcss/components"; 5 | @import "tailwindcss/utilities"; 6 | 7 | body { 8 | font-family: 'Outfit', sans-serif; 9 | } 10 | 11 | /* This file is for your main application CSS */ 12 | 13 | /* Alerts and form errors used by phx.new */ 14 | .alert { 15 | padding: 15px; 16 | margin-bottom: 20px; 17 | border: 1px solid transparent; 18 | border-radius: 4px; 19 | } 20 | .alert-info { 21 | color: #31708f; 22 | background-color: #d9edf7; 23 | border-color: #bce8f1; 24 | } 25 | .alert-warning { 26 | color: #8a6d3b; 27 | background-color: #fcf8e3; 28 | border-color: #faebcc; 29 | } 30 | .alert-danger { 31 | color: #a94442; 32 | background-color: #f2dede; 33 | border-color: #ebccd1; 34 | } 35 | .alert p { 36 | margin-bottom: 0; 37 | } 38 | .alert:empty { 39 | display: none; 40 | } 41 | .invalid-feedback { 42 | color: #a94442; 43 | display: block; 44 | margin: -1rem 0 2rem; 45 | } 46 | 47 | /* LiveView specific classes for your customization */ 48 | .phx-no-feedback.invalid-feedback, 49 | .phx-no-feedback .invalid-feedback { 50 | display: none; 51 | } 52 | 53 | .phx-click-loading { 54 | opacity: 0.5; 55 | transition: opacity 1s ease-out; 56 | } 57 | 58 | .phx-loading{ 59 | cursor: wait; 60 | } 61 | 62 | .phx-modal { 63 | opacity: 1!important; 64 | position: fixed; 65 | z-index: 1; 66 | left: 0; 67 | top: 0; 68 | width: 100%; 69 | height: 100%; 70 | overflow: auto; 71 | background-color: rgba(0,0,0,0.4); 72 | } 73 | 74 | .phx-modal-content { 75 | background-color: #fefefe; 76 | margin: 15vh auto; 77 | padding: 20px; 78 | border: 1px solid #888; 79 | width: 80%; 80 | } 81 | 82 | .phx-modal-close { 83 | color: #aaa; 84 | float: right; 85 | font-size: 28px; 86 | font-weight: bold; 87 | } 88 | 89 | .phx-modal-close:hover, 90 | .phx-modal-close:focus { 91 | color: black; 92 | text-decoration: none; 93 | cursor: pointer; 94 | } 95 | 96 | .fade-in-scale { 97 | animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys; 98 | } 99 | 100 | .fade-out-scale { 101 | animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys; 102 | } 103 | 104 | .fade-in { 105 | animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys; 106 | } 107 | .fade-out { 108 | animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys; 109 | } 110 | 111 | @keyframes fade-in-scale-keys{ 112 | 0% { scale: 0.95; opacity: 0; } 113 | 100% { scale: 1.0; opacity: 1; } 114 | } 115 | 116 | @keyframes fade-out-scale-keys{ 117 | 0% { scale: 1.0; opacity: 1; } 118 | 100% { scale: 0.95; opacity: 0; } 119 | } 120 | 121 | @keyframes fade-in-keys{ 122 | 0% { opacity: 0; } 123 | 100% { opacity: 1; } 124 | } 125 | 126 | @keyframes fade-out-keys{ 127 | 0% { opacity: 1; } 128 | 100% { opacity: 0; } 129 | } 130 | 131 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // We import the CSS which is extracted to its own file by esbuild. 2 | // Remove this line if you add a your own CSS build pipeline (e.g postcss). 3 | 4 | // If you want to use Phoenix channels, run `mix help phx.gen.channel` 5 | // to get started and then uncomment the line below. 6 | // import "./user_socket.js" 7 | 8 | // You can include dependencies in two ways. 9 | // 10 | // The simplest option is to put them in assets/vendor and 11 | // import them using relative paths: 12 | // 13 | // import "../vendor/some-package.js" 14 | // 15 | // Alternatively, you can `npm install some-package --prefix assets` and import 16 | // them using a path starting with the package name: 17 | // 18 | // import "some-package" 19 | // 20 | 21 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 22 | import "phoenix_html"; 23 | // Establish Phoenix Socket and LiveView configuration. 24 | import { Socket } from "phoenix"; 25 | import { LiveSocket } from "phoenix_live_view"; 26 | import topbar from "../vendor/topbar"; 27 | 28 | let csrfToken = document 29 | .querySelector("meta[name='csrf-token']") 30 | .getAttribute("content"); 31 | let liveSocket = new LiveSocket("/live", Socket, { 32 | params: { _csrf_token: csrfToken }, 33 | }); 34 | 35 | window.addEventListener("phx:share", () => { 36 | const element = document.getElementById("share-button"); 37 | 38 | if (navigator.share) { 39 | navigator 40 | .share({ 41 | title: "Todoish!", 42 | text: "Add some things!", 43 | url: window.location.href, 44 | }) 45 | .then(() => { 46 | if (element) { 47 | element.innerText = "Shared!"; 48 | 49 | setTimeout(() => { 50 | element.innerText = "Share this list!"; 51 | }, 1000); 52 | } 53 | }) 54 | .catch((error) => console.log("Error sharing", error)); 55 | } else { 56 | navigator.clipboard.writeText(window.location.href); 57 | 58 | if (element) { 59 | element.innerText = "Copied to clipboard!"; 60 | 61 | setTimeout(() => { 62 | element.innerText = "Share this list!"; 63 | }, 1000); 64 | } 65 | } 66 | }); 67 | 68 | window.addEventListener("phx:save-list", () => { 69 | const element = document.getElementById("save-list-button"); 70 | 71 | if (element) { 72 | element.innerText = "List saved!"; 73 | 74 | setTimeout(() => { 75 | element.innerText = "Save list"; 76 | }, 1000); 77 | } 78 | }); 79 | 80 | // Show progress bar on live navigation and form submits 81 | topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }); 82 | window.addEventListener("phx:page-loading-start", (info) => topbar.show()); 83 | window.addEventListener("phx:page-loading-stop", (info) => topbar.hide()); 84 | 85 | // connect if there are any LiveViews on the page 86 | liveSocket.connect(); 87 | 88 | // expose liveSocket on window for web console debug logs and latency simulation: 89 | // >> liveSocket.enableDebug() 90 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 91 | // >> liveSocket.disableLatencySim() 92 | window.liveSocket = liveSocket; 93 | -------------------------------------------------------------------------------- /assets/vendor/topbar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license MIT 3 | * topbar 1.0.0, 2021-01-06 4 | * https://buunguyen.github.io/topbar 5 | * Copyright (c) 2021 Buu Nguyen 6 | */ 7 | (function (window, document) { 8 | "use strict"; 9 | 10 | // https://gist.github.com/paulirish/1579671 11 | (function () { 12 | var lastTime = 0; 13 | var vendors = ["ms", "moz", "webkit", "o"]; 14 | for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { 15 | window.requestAnimationFrame = 16 | window[vendors[x] + "RequestAnimationFrame"]; 17 | window.cancelAnimationFrame = 18 | window[vendors[x] + "CancelAnimationFrame"] || 19 | window[vendors[x] + "CancelRequestAnimationFrame"]; 20 | } 21 | if (!window.requestAnimationFrame) 22 | window.requestAnimationFrame = function (callback, element) { 23 | var currTime = new Date().getTime(); 24 | var timeToCall = Math.max(0, 16 - (currTime - lastTime)); 25 | var id = window.setTimeout(function () { 26 | callback(currTime + timeToCall); 27 | }, timeToCall); 28 | lastTime = currTime + timeToCall; 29 | return id; 30 | }; 31 | if (!window.cancelAnimationFrame) 32 | window.cancelAnimationFrame = function (id) { 33 | clearTimeout(id); 34 | }; 35 | })(); 36 | 37 | var canvas, 38 | progressTimerId, 39 | fadeTimerId, 40 | currentProgress, 41 | showing, 42 | addEvent = function (elem, type, handler) { 43 | if (elem.addEventListener) elem.addEventListener(type, handler, false); 44 | else if (elem.attachEvent) elem.attachEvent("on" + type, handler); 45 | else elem["on" + type] = handler; 46 | }, 47 | options = { 48 | autoRun: true, 49 | barThickness: 3, 50 | barColors: { 51 | 0: "rgba(26, 188, 156, .9)", 52 | ".25": "rgba(52, 152, 219, .9)", 53 | ".50": "rgba(241, 196, 15, .9)", 54 | ".75": "rgba(230, 126, 34, .9)", 55 | "1.0": "rgba(211, 84, 0, .9)", 56 | }, 57 | shadowBlur: 10, 58 | shadowColor: "rgba(0, 0, 0, .6)", 59 | className: null, 60 | }, 61 | repaint = function () { 62 | canvas.width = window.innerWidth; 63 | canvas.height = options.barThickness * 5; // need space for shadow 64 | 65 | var ctx = canvas.getContext("2d"); 66 | ctx.shadowBlur = options.shadowBlur; 67 | ctx.shadowColor = options.shadowColor; 68 | 69 | var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); 70 | for (var stop in options.barColors) 71 | lineGradient.addColorStop(stop, options.barColors[stop]); 72 | ctx.lineWidth = options.barThickness; 73 | ctx.beginPath(); 74 | ctx.moveTo(0, options.barThickness / 2); 75 | ctx.lineTo( 76 | Math.ceil(currentProgress * canvas.width), 77 | options.barThickness / 2 78 | ); 79 | ctx.strokeStyle = lineGradient; 80 | ctx.stroke(); 81 | }, 82 | createCanvas = function () { 83 | canvas = document.createElement("canvas"); 84 | var style = canvas.style; 85 | style.position = "fixed"; 86 | style.top = style.left = style.right = style.margin = style.padding = 0; 87 | style.zIndex = 100001; 88 | style.display = "none"; 89 | if (options.className) canvas.classList.add(options.className); 90 | document.body.appendChild(canvas); 91 | addEvent(window, "resize", repaint); 92 | }, 93 | topbar = { 94 | config: function (opts) { 95 | for (var key in opts) 96 | if (options.hasOwnProperty(key)) options[key] = opts[key]; 97 | }, 98 | show: function () { 99 | if (showing) return; 100 | showing = true; 101 | if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); 102 | if (!canvas) createCanvas(); 103 | canvas.style.opacity = 1; 104 | canvas.style.display = "block"; 105 | topbar.progress(0); 106 | if (options.autoRun) { 107 | (function loop() { 108 | progressTimerId = window.requestAnimationFrame(loop); 109 | topbar.progress( 110 | "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) 111 | ); 112 | })(); 113 | } 114 | }, 115 | progress: function (to) { 116 | if (typeof to === "undefined") return currentProgress; 117 | if (typeof to === "string") { 118 | to = 119 | (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 120 | ? currentProgress 121 | : 0) + parseFloat(to); 122 | } 123 | currentProgress = to > 1 ? 1 : to; 124 | repaint(); 125 | return currentProgress; 126 | }, 127 | hide: function () { 128 | if (!showing) return; 129 | showing = false; 130 | if (progressTimerId != null) { 131 | window.cancelAnimationFrame(progressTimerId); 132 | progressTimerId = null; 133 | } 134 | (function loop() { 135 | if (topbar.progress("+.1") >= 1) { 136 | canvas.style.opacity -= 0.05; 137 | if (canvas.style.opacity <= 0.05) { 138 | canvas.style.display = "none"; 139 | fadeTimerId = null; 140 | return; 141 | } 142 | } 143 | fadeTimerId = window.requestAnimationFrame(loop); 144 | })(); 145 | }, 146 | }; 147 | 148 | if (typeof module === "object" && typeof module.exports === "object") { 149 | module.exports = topbar; 150 | } else if (typeof define === "function" && define.amd) { 151 | define(function () { 152 | return topbar; 153 | }); 154 | } else { 155 | this.topbar = topbar; 156 | } 157 | }.call(this, window, document)); 158 | -------------------------------------------------------------------------------- /lib/todoish_web/live/list.ex: -------------------------------------------------------------------------------- 1 | defmodule TodoishWeb.Live.List do 2 | use Phoenix.LiveView 3 | use Phoenix.HTML 4 | 5 | require Ash.Query 6 | 7 | def render(assigns) do 8 | ~L""" 9 |
10 |
11 |

12 |
<%= @list.title %>
13 |
14 |

15 |

16 | <%= @list.description %> 17 |

18 |
19 |
20 | <%= for item <- Enum.reverse(@list.items) do %> 21 |
22 |
23 | <%= if item.status == :incompleted do %> 24 | ✅ 25 | <% else %> 26 | 🔁 27 | <% end %> 28 |
29 |
"> 30 | <%= item.title %> 31 |
32 |
🗑️
33 |
34 | <% end %> 35 | <%= f = form_for @form, "#", [phx_submit: :save, phx_change: :validate, class: "flex flex-row justify-center w-full gap-2"] %> 36 | <%= submit "➕", [class: ["w-6 text-xl"]] %> 37 | <%= text_input f, :title, [id: "new-todo", placeholder: "New item!", class: ["w-56 md:w-96 h-12 text-sm md:text-base rounded-md bg-base-100"]] %> 38 |
39 | 40 |
41 | <%= if @error != nil do %> 42 |
<%= @error %>
43 | <% end %> 44 |
45 |
Share this list!
46 |
47 |
48 | <%= if @user_id do %> 49 | 50 | <% else %> 51 | Sign in to save 52 | <% end %> 53 |
54 |
55 | """ 56 | end 57 | 58 | def mount(%{"url_id" => url_id}, sessions, socket) do 59 | user_id = 60 | if sessions["user"] do 61 | sessions["user"] 62 | |> URI.decode_query() 63 | |> Map.get("user?id") 64 | else 65 | nil 66 | end 67 | 68 | list = 69 | Todoish.Entries.List 70 | |> Ash.Query.filter(url_id == ^url_id) 71 | |> Ash.Query.limit(1) 72 | |> Ash.Query.select([:title, :id, :url_id, :description]) 73 | |> Ash.Query.load(items: Ash.Query.sort(Todoish.Entries.Item, inserted_at: :desc)) 74 | |> Todoish.Entries.read_one!() 75 | 76 | if list != nil do 77 | TodoishWeb.Endpoint.subscribe("item:list:#{list.id}") 78 | 79 | form = 80 | AshPhoenix.Form.for_create( 81 | Todoish.Entries.Item, 82 | :create 83 | ) 84 | 85 | socket = 86 | socket 87 | |> assign(:error, nil) 88 | |> assign(:list, list) 89 | |> assign(:form, form) 90 | |> assign(:page_title, list.title) 91 | |> assign(:user_id, user_id) 92 | 93 | {:ok, socket} 94 | else 95 | {:ok, push_redirect(socket, to: "/")} 96 | end 97 | end 98 | 99 | def handle_event("save", %{"form" => form}, socket) do 100 | Todoish.Entries.Item 101 | |> AshPhoenix.Form.for_create(:new, 102 | api: Todoish.Entries, 103 | prepare_params: fn params, _ -> 104 | Map.put(params, "list_id", socket.assigns.list.id) 105 | end 106 | ) 107 | |> AshPhoenix.Form.validate(form) 108 | |> AshPhoenix.Form.submit() 109 | |> case do 110 | {:ok, item} -> 111 | items = socket.assigns.list.items 112 | 113 | list = %{socket.assigns.list | items: [item | items]} 114 | 115 | {:noreply, assign(socket, :list, list)} 116 | 117 | {:error, form} -> 118 | socket = 119 | socket 120 | |> assign(form: form) 121 | |> assign(error: "Make sure to put something todo 👆") 122 | 123 | {:noreply, socket} 124 | end 125 | end 126 | 127 | def handle_event("save-list", _from, socket) do 128 | if socket.assigns.user_id do 129 | user_id = socket.assigns.user_id 130 | list_id = socket.assigns.list.id 131 | 132 | user = 133 | Todoish.Entries.User 134 | |> Ash.Query.filter(id == ^user_id) 135 | |> Ash.Query.limit(1) 136 | |> Ash.Query.select([]) 137 | |> Ash.Query.load(:lists) 138 | |> Todoish.Entries.read_one!() 139 | 140 | case Enum.find(user.lists, fn l -> l.id == list_id end) do 141 | nil -> 142 | Todoish.Entries.UsersLists 143 | |> Ash.Changeset.for_create(:new, %{list_id: list_id, user_id: user_id}) 144 | |> Todoish.Entries.create!() 145 | 146 | {:noreply, push_event(socket, "save-list", %{})} 147 | 148 | _ -> 149 | {:noreply, push_event(socket, "save-list", %{})} 150 | end 151 | else 152 | {:noreply, socket} 153 | end 154 | end 155 | 156 | def handle_event("validate", _form, socket) do 157 | if socket.assigns.error != nil do 158 | {:noreply, assign(socket, :error, nil)} 159 | else 160 | {:noreply, socket} 161 | end 162 | end 163 | 164 | def handle_event("done", %{"id" => id}, socket) do 165 | list = socket.assigns.list 166 | 167 | item = Enum.find(list.items, &(&1.id == id)) 168 | 169 | if item != nil do 170 | item = 171 | if item.status == :completed do 172 | Ash.Changeset.for_update(item, :incomplete) |> Todoish.Entries.update!() 173 | else 174 | Ash.Changeset.for_update(item, :complete) |> Todoish.Entries.update!() 175 | end 176 | 177 | item_index = Enum.find_index(list.items, &(&1.id == id)) 178 | 179 | items = List.replace_at(list.items, item_index, item) 180 | 181 | list = %{list | items: items} 182 | 183 | {:noreply, assign(socket, :list, list)} 184 | else 185 | {:noreply, socket} 186 | end 187 | end 188 | 189 | def handle_event("delete", %{"id" => id}, socket) do 190 | list = socket.assigns.list 191 | 192 | item = Enum.find(list.items, &(&1.id == id)) 193 | 194 | if item != nil do 195 | item 196 | |> Ash.Changeset.for_destroy(:destroy) 197 | |> Todoish.Entries.destroy!() 198 | 199 | items = List.delete(list.items, item) 200 | 201 | list = %{list | items: items} 202 | 203 | {:noreply, assign(socket, :list, list)} 204 | end 205 | end 206 | 207 | def handle_event("share", _value, socket) do 208 | {:noreply, push_event(socket, "share", %{})} 209 | end 210 | 211 | def handle_info(%{event: "item-added", payload: payload}, socket) do 212 | new_item = payload.payload.data 213 | 214 | items = socket.assigns.list.items 215 | 216 | item_in_list = Enum.find(items, &(&1.id == new_item.id)) 217 | 218 | if item_in_list do 219 | {:noreply, socket} 220 | else 221 | items = [new_item | items] 222 | list = %{socket.assigns.list | items: items} 223 | 224 | {:noreply, assign(socket, :list, list)} 225 | end 226 | end 227 | 228 | def handle_info(%{event: "item-updated", payload: payload}, socket) do 229 | updated_item = payload.payload.data 230 | 231 | items = socket.assigns.list.items 232 | 233 | item_index = Enum.find_index(items, &(&1.id == updated_item.id)) 234 | 235 | if item_index != nil do 236 | items = List.replace_at(items, item_index, updated_item) 237 | list = %{socket.assigns.list | items: items} 238 | 239 | {:noreply, assign(socket, :list, list)} 240 | else 241 | {:noreply, socket} 242 | end 243 | end 244 | 245 | def handle_info(%{event: "item-deleted", payload: payload}, socket) do 246 | deleted_item = payload.payload.data 247 | 248 | items = socket.assigns.list.items 249 | 250 | item = Enum.find(items, &(&1.id == deleted_item.id)) 251 | 252 | if item != nil do 253 | items = List.delete(items, item) 254 | list = %{socket.assigns.list | items: items} 255 | 256 | {:noreply, assign(socket, :list, list)} 257 | else 258 | {:noreply, socket} 259 | end 260 | end 261 | end 262 | -------------------------------------------------------------------------------- /assets/css/phoenix.css: -------------------------------------------------------------------------------- 1 | /* Includes some default style for the starter application. 2 | * This can be safely deleted to start fresh. 3 | */ 4 | 5 | /* Milligram v1.4.1 https://milligram.github.io 6 | * Copyright (c) 2020 CJ Patoilo Licensed under the MIT license 7 | */ 8 | 9 | *,*:after,*:before{box-sizing:inherit}html{box-sizing:border-box;font-size:62.5%}body{color:#000000;font-family:'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;font-size:1.6em;font-weight:300;letter-spacing:.01em;line-height:1.6}blockquote{border-left:0.3rem solid #d1d1d1;margin-left:0;margin-right:0;padding:1rem 1.5rem}blockquote *:last-child{margin-bottom:0}.button,button,input[type='button'],input[type='reset'],input[type='submit']{background-color:#0069d9;border:0.1rem solid #0069d9;border-radius:.4rem;color:#fff;cursor:pointer;display:inline-block;font-size:1.1rem;font-weight:700;height:3.8rem;letter-spacing:.1rem;line-height:3.8rem;padding:0 3.0rem;text-align:center;text-decoration:none;text-transform:uppercase;white-space:nowrap}.button:focus,.button:hover,button:focus,button:hover,input[type='button']:focus,input[type='button']:hover,input[type='reset']:focus,input[type='reset']:hover,input[type='submit']:focus,input[type='submit']:hover{background-color:#606c76;border-color:#606c76;color:#fff;outline:0}.button[disabled],button[disabled],input[type='button'][disabled],input[type='reset'][disabled],input[type='submit'][disabled]{cursor:default;opacity:.5}.button[disabled]:focus,.button[disabled]:hover,button[disabled]:focus,button[disabled]:hover,input[type='button'][disabled]:focus,input[type='button'][disabled]:hover,input[type='reset'][disabled]:focus,input[type='reset'][disabled]:hover,input[type='submit'][disabled]:focus,input[type='submit'][disabled]:hover{background-color:#0069d9;border-color:#0069d9}.button.button-outline,button.button-outline,input[type='button'].button-outline,input[type='reset'].button-outline,input[type='submit'].button-outline{background-color:transparent;color:#0069d9}.button.button-outline:focus,.button.button-outline:hover,button.button-outline:focus,button.button-outline:hover,input[type='button'].button-outline:focus,input[type='button'].button-outline:hover,input[type='reset'].button-outline:focus,input[type='reset'].button-outline:hover,input[type='submit'].button-outline:focus,input[type='submit'].button-outline:hover{background-color:transparent;border-color:#606c76;color:#606c76}.button.button-outline[disabled]:focus,.button.button-outline[disabled]:hover,button.button-outline[disabled]:focus,button.button-outline[disabled]:hover,input[type='button'].button-outline[disabled]:focus,input[type='button'].button-outline[disabled]:hover,input[type='reset'].button-outline[disabled]:focus,input[type='reset'].button-outline[disabled]:hover,input[type='submit'].button-outline[disabled]:focus,input[type='submit'].button-outline[disabled]:hover{border-color:inherit;color:#0069d9}.button.button-clear,button.button-clear,input[type='button'].button-clear,input[type='reset'].button-clear,input[type='submit'].button-clear{background-color:transparent;border-color:transparent;color:#0069d9}.button.button-clear:focus,.button.button-clear:hover,button.button-clear:focus,button.button-clear:hover,input[type='button'].button-clear:focus,input[type='button'].button-clear:hover,input[type='reset'].button-clear:focus,input[type='reset'].button-clear:hover,input[type='submit'].button-clear:focus,input[type='submit'].button-clear:hover{background-color:transparent;border-color:transparent;color:#606c76}.button.button-clear[disabled]:focus,.button.button-clear[disabled]:hover,button.button-clear[disabled]:focus,button.button-clear[disabled]:hover,input[type='button'].button-clear[disabled]:focus,input[type='button'].button-clear[disabled]:hover,input[type='reset'].button-clear[disabled]:focus,input[type='reset'].button-clear[disabled]:hover,input[type='submit'].button-clear[disabled]:focus,input[type='submit'].button-clear[disabled]:hover{color:#0069d9}code{background:#f4f5f6;border-radius:.4rem;font-size:86%;margin:0 .2rem;padding:.2rem .5rem;white-space:nowrap}pre{background:#f4f5f6;border-left:0.3rem solid #0069d9;overflow-y:hidden}pre>code{border-radius:0;display:block;padding:1rem 1.5rem;white-space:pre}hr{border:0;border-top:0.1rem solid #f4f5f6;margin:3.0rem 0}input[type='color'],input[type='date'],input[type='datetime'],input[type='datetime-local'],input[type='email'],input[type='month'],input[type='number'],input[type='password'],input[type='search'],input[type='tel'],input[type='text'],input[type='url'],input[type='week'],input:not([type]),textarea,select{-webkit-appearance:none;background-color:transparent;border:0.1rem solid #d1d1d1;border-radius:.4rem;box-shadow:none;box-sizing:inherit;height:3.8rem;padding:.6rem 1.0rem .7rem;width:100%}input[type='color']:focus,input[type='date']:focus,input[type='datetime']:focus,input[type='datetime-local']:focus,input[type='email']:focus,input[type='month']:focus,input[type='number']:focus,input[type='password']:focus,input[type='search']:focus,input[type='tel']:focus,input[type='text']:focus,input[type='url']:focus,input[type='week']:focus,input:not([type]):focus,textarea:focus,select:focus{border-color:#0069d9;outline:0}select{background:url('data:image/svg+xml;utf8,') center right no-repeat;padding-right:3.0rem}select:focus{background-image:url('data:image/svg+xml;utf8,')}select[multiple]{background:none;height:auto}textarea{min-height:6.5rem}label,legend{display:block;font-size:1.6rem;font-weight:700;margin-bottom:.5rem}fieldset{border-width:0;padding:0}input[type='checkbox'],input[type='radio']{display:inline}.label-inline{display:inline-block;font-weight:normal;margin-left:.5rem}.container{margin:0 auto;max-width:112.0rem;padding:0 2.0rem;position:relative;width:100%}.row{display:flex;flex-direction:column;padding:0;width:100%}.row.row-no-padding{padding:0}.row.row-no-padding>.column{padding:0}.row.row-wrap{flex-wrap:wrap}.row.row-top{align-items:flex-start}.row.row-bottom{align-items:flex-end}.row.row-center{align-items:center}.row.row-stretch{align-items:stretch}.row.row-baseline{align-items:baseline}.row .column{display:block;flex:1 1 auto;margin-left:0;max-width:100%;width:100%}.row .column.column-offset-10{margin-left:10%}.row .column.column-offset-20{margin-left:20%}.row .column.column-offset-25{margin-left:25%}.row .column.column-offset-33,.row .column.column-offset-34{margin-left:33.3333%}.row .column.column-offset-40{margin-left:40%}.row .column.column-offset-50{margin-left:50%}.row .column.column-offset-60{margin-left:60%}.row .column.column-offset-66,.row .column.column-offset-67{margin-left:66.6666%}.row .column.column-offset-75{margin-left:75%}.row .column.column-offset-80{margin-left:80%}.row .column.column-offset-90{margin-left:90%}.row .column.column-10{flex:0 0 10%;max-width:10%}.row .column.column-20{flex:0 0 20%;max-width:20%}.row .column.column-25{flex:0 0 25%;max-width:25%}.row .column.column-33,.row .column.column-34{flex:0 0 33.3333%;max-width:33.3333%}.row .column.column-40{flex:0 0 40%;max-width:40%}.row .column.column-50{flex:0 0 50%;max-width:50%}.row .column.column-60{flex:0 0 60%;max-width:60%}.row .column.column-66,.row .column.column-67{flex:0 0 66.6666%;max-width:66.6666%}.row .column.column-75{flex:0 0 75%;max-width:75%}.row .column.column-80{flex:0 0 80%;max-width:80%}.row .column.column-90{flex:0 0 90%;max-width:90%}.row .column .column-top{align-self:flex-start}.row .column .column-bottom{align-self:flex-end}.row .column .column-center{align-self:center}@media (min-width: 40rem){.row{flex-direction:row;margin-left:-1.0rem;width:calc(100% + 2.0rem)}.row .column{margin-bottom:inherit;padding:0 1.0rem}}a{color:#0069d9;text-decoration:none}a:focus,a:hover{color:#606c76}dl,ol,ul{list-style:none;margin-top:0;padding-left:0}dl dl,dl ol,dl ul,ol dl,ol ol,ol ul,ul dl,ul ol,ul ul{font-size:90%;margin:1.5rem 0 1.5rem 3.0rem}ol{list-style:decimal inside}ul{list-style:circle inside}.button,button,dd,dt,li{margin-bottom:1.0rem}fieldset,input,select,textarea{margin-bottom:1.5rem}blockquote,dl,figure,form,ol,p,pre,table,ul{margin-bottom:2.5rem}table{border-spacing:0;display:block;overflow-x:auto;text-align:left;width:100%}td,th{border-bottom:0.1rem solid #e1e1e1;padding:1.2rem 1.5rem}td:first-child,th:first-child{padding-left:0}td:last-child,th:last-child{padding-right:0}@media (min-width: 40rem){table{display:table;overflow-x:initial}}b,strong{font-weight:bold}p{margin-top:0}h1,h2,h3,h4,h5,h6{font-weight:300;letter-spacing:-.1rem;margin-bottom:2.0rem;margin-top:0}h1{font-size:4.6rem;line-height:1.2}h2{font-size:3.6rem;line-height:1.25}h3{font-size:2.8rem;line-height:1.3}h4{font-size:2.2rem;letter-spacing:-.08rem;line-height:1.35}h5{font-size:1.8rem;letter-spacing:-.05rem;line-height:1.5}h6{font-size:1.6rem;letter-spacing:0;line-height:1.4}img{max-width:100%}.clearfix:after{clear:both;content:' ';display:table}.float-left{float:left}.float-right{float:right} 10 | 11 | /* General style */ 12 | h1{font-size: 3.6rem; line-height: 1.25} 13 | h2{font-size: 2.8rem; line-height: 1.3} 14 | h3{font-size: 2.2rem; letter-spacing: -.08rem; line-height: 1.35} 15 | h4{font-size: 1.8rem; letter-spacing: -.05rem; line-height: 1.5} 16 | h5{font-size: 1.6rem; letter-spacing: 0; line-height: 1.4} 17 | h6{font-size: 1.4rem; letter-spacing: 0; line-height: 1.2} 18 | pre{padding: 1em;} 19 | 20 | .container{ 21 | margin: 0 auto; 22 | max-width: 80.0rem; 23 | padding: 0 2.0rem; 24 | position: relative; 25 | width: 100% 26 | } 27 | select { 28 | width: auto; 29 | } 30 | 31 | /* Phoenix promo and logo */ 32 | .phx-hero { 33 | text-align: center; 34 | border-bottom: 1px solid #e3e3e3; 35 | background: #eee; 36 | border-radius: 6px; 37 | padding: 3em 3em 1em; 38 | margin-bottom: 3rem; 39 | font-weight: 200; 40 | font-size: 120%; 41 | } 42 | .phx-hero input { 43 | background: #ffffff; 44 | } 45 | .phx-logo { 46 | min-width: 300px; 47 | margin: 1rem; 48 | display: block; 49 | } 50 | .phx-logo img { 51 | width: auto; 52 | display: block; 53 | } 54 | 55 | /* Headers */ 56 | header { 57 | width: 100%; 58 | background: #fdfdfd; 59 | border-bottom: 1px solid #eaeaea; 60 | margin-bottom: 2rem; 61 | } 62 | header section { 63 | align-items: center; 64 | display: flex; 65 | flex-direction: column; 66 | justify-content: space-between; 67 | } 68 | header section :first-child { 69 | order: 2; 70 | } 71 | header section :last-child { 72 | order: 1; 73 | } 74 | header nav ul, 75 | header nav li { 76 | margin: 0; 77 | padding: 0; 78 | display: block; 79 | text-align: right; 80 | white-space: nowrap; 81 | } 82 | header nav ul { 83 | margin: 1rem; 84 | margin-top: 0; 85 | } 86 | header nav a { 87 | display: block; 88 | } 89 | 90 | @media (min-width: 40.0rem) { /* Small devices (landscape phones, 576px and up) */ 91 | header section { 92 | flex-direction: row; 93 | } 94 | header nav ul { 95 | margin: 1rem; 96 | } 97 | .phx-logo { 98 | flex-basis: 527px; 99 | margin: 2rem 1rem; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "ash": {:hex, :ash, "2.6.10", "8c4d8ce3ce0174474ea51cdacc582d444feffa63c52e194dd4d6a5f912d348ce", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: true]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:spark, "~> 0.3 and >= 0.3.12", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.5.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "60396adc1d66aa6cd69d95aa625b92f394ab89f21721a52d01b39f79857a1103"}, 3 | "ash_authentication": {:hex, :ash_authentication, "3.9.3", "4a86df093ef9a344d9058537d9fa0d8a520572921d5abedef3838176ae4e2298", [:mix], [{:ash, "~> 2.5 and >= 2.5.11", [hex: :ash, repo: "hexpm", optional: false]}, {:assent, "~> 0.2", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:mint, "~> 1.4", [hex: :mint, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 0.4 and >= 0.4.1", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "58ab16b3ed930f8a69f5c02e0718919db4ad8e7b4b609251f37bb74c25d3cf34"}, 4 | "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "1.5.0", "f5c609545fdaa86d4fe4a61a16d3737811c12c7a95eaaf8950064a5193951b69", [:mix], [{:ash, "~> 2.2", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 3.5", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 1.1", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.2", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "6916f725aa8e5a6d36bb23226aaf413dd0b7af8a59c814ae656021645c6e716c"}, 5 | "ash_phoenix": {:hex, :ash_phoenix, "1.2.6", "2ba4566bda47eb23bd5b1e726102c8a58fb0bb018897e756e53d31ba694961d4", [:mix], [{:ash, "~> 2.5 and >= 2.5.10", [hex: :ash, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.15", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "bc7039eaf3840d756118611a0ab40ea6ca3c796fcdecc8dae6b17b57b0b1764a"}, 6 | "ash_postgres": {:hex, :ash_postgres, "1.3.14", "e36ee82122de1495084b21a7aceb9c49e1431a935e37c351bbecc3e168eb8803", [:mix], [{:ash, "~> 2.6 and >= 2.6.10", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.5", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "8148921d72f2d622b591e3975a8e912e757504045271a8fffc4c38df51ba2ab5"}, 7 | "assent": {:hex, :assent, "0.2.1", "46ad0ed92b72330f38c60bc03c528e8408475dc386f48d4ecd18833cfa581b9f", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "58c558b6029ffa287e15b38c8e07cd99f0b24e4846c52abad0c0a6225c4873bc"}, 8 | "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.0.1", "9be815469e6bfefec40fa74658ecbbe6897acfb57614df1416eeccd4903f602c", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "486bb95efb645d1efc6794c1ddd776a186a9a713abf06f45708a6ce324fb96cf"}, 9 | "castore": {:hex, :castore, "0.1.22", "4127549e411bedd012ca3a308dede574f43819fe9394254ca55ab4895abfa1a2", [:mix], [], "hexpm", "c17576df47eb5aa1ee40cc4134316a99f5cad3e215d5c77b8dd3cfef12a22cac"}, 10 | "comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"}, 11 | "comparable": {:hex, :comparable, "1.0.0", "bb669e91cedd14ae9937053e5bcbc3c52bb2f22422611f43b6e38367d94a495f", [:mix], [{:typable, "~> 0.1", [hex: :typable, repo: "hexpm", optional: false]}], "hexpm", "277c11eeb1cd726e7cd41c6c199e7e52fa16ee6830b45ad4cdc62e51f62eb60c"}, 12 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, 13 | "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, 14 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 15 | "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, 16 | "db_connection": {:hex, :db_connection, "2.4.3", "3b9aac9f27347ec65b271847e6baeb4443d8474289bd18c1d6f4de655b70c94d", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c127c15b0fa6cfb32eed07465e05da6c815b032508d4ed7c116122871df73c12"}, 17 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, 18 | "docsh": {:hex, :docsh, "0.7.2", "f893d5317a0e14269dd7fe79cf95fb6b9ba23513da0480ec6e77c73221cae4f2", [:rebar3], [{:providers, "1.8.1", [hex: :providers, repo: "hexpm", optional: false]}], "hexpm", "4e7db461bb07540d2bc3d366b8513f0197712d0495bb85744f367d3815076134"}, 19 | "ecto": {:hex, :ecto, "3.9.4", "3ee68e25dbe0c36f980f1ba5dd41ee0d3eb0873bccae8aeaf1a2647242bffa35", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "de5f988c142a3aa4ec18b85a4ec34a2390b65b24f02385c1144252ff6ff8ee75"}, 20 | "ecto_sql": {:hex, :ecto_sql, "3.9.2", "34227501abe92dba10d9c3495ab6770e75e79b836d114c41108a4bf2ce200ad5", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1eb5eeb4358fdbcd42eac11c1fbd87e3affd7904e639d77903c1358b2abd3f70"}, 21 | "elixir_make": {:hex, :elixir_make, "0.7.5", "784cc00f5fa24239067cc04d449437dcc5f59353c44eb08f188b2b146568738a", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "c3d63e8d5c92fa3880d89ecd41de59473fa2e83eeb68148155e25e8b95aa2887"}, 22 | "elixir_sense": {:git, "https://github.com/elixir-lsp/elixir_sense.git", "d4b797b0bf2729a84b822096df581e3465adc0d4", []}, 23 | "esbuild": {:hex, :esbuild, "0.6.1", "a774bfa7b4512a1211bf15880b462be12a4c48ed753a170c68c63b2c95888150", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "569f7409fb5a932211573fc20e2a930a0d5cf3377c5b4f6506c651b1783a1678"}, 24 | "ets": {:hex, :ets, "0.8.1", "8ff9bcda5682b98493f8878fc9dbd990e48d566cba8cce59f7c2a78130da29ea", [:mix], [], "hexpm", "6be41b50adb5bc5c43626f25ea2d0af1f4a242fb3fad8d53f0c67c20b78915cc"}, 25 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 26 | "floki": {:hex, :floki, "0.34.1", "b1f9c413d91140230788b173906065f6f8906bbbf5b3f0d3c626301aeeef44c5", [:mix], [], "hexpm", "cc9b62312a45c1239ca8f65e05377ef8c646f3d7712e5727a9b47c43c946e885"}, 27 | "getopt": {:hex, :getopt, "1.0.1", "c73a9fa687b217f2ff79f68a3b637711bb1936e712b521d8ce466b29cbf7808a", [:rebar3], [], "hexpm", "53e1ab83b9ceb65c9672d3e7a35b8092e9bdc9b3ee80721471a161c10c59959c"}, 28 | "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, 29 | "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, 30 | "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, 31 | "joken": {:hex, :joken, "2.6.0", "b9dd9b6d52e3e6fcb6c65e151ad38bf4bc286382b5b6f97079c47ade6b1bcc6a", [:mix], [{:jose, "~> 1.11.5", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5a95b05a71cd0b54abd35378aeb1d487a23a52c324fa7efdffc512b655b5aaa7"}, 32 | "jose": {:hex, :jose, "1.11.5", "3bc2d75ffa5e2c941ca93e5696b54978323191988eb8d225c2e663ddfefd515e", [:mix, :rebar3], [], "hexpm", "dcd3b215bafe02ea7c5b23dafd3eb8062a5cd8f2d904fd9caa323d37034ab384"}, 33 | "libgraph": {:hex, :libgraph, "0.13.3", "20732b7bafb933dcf7351c479e03076ebd14a85fd3202c67a1c197f4f7c2466b", [:mix], [], "hexpm", "78f2576eef615440b46f10060b1de1c86640441422832052686df53dc3c148c6"}, 34 | "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, 35 | "mint": {:hex, :mint, "1.4.2", "50330223429a6e1260b2ca5415f69b0ab086141bc76dc2fbf34d7c389a6675b2", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "ce75a5bbcc59b4d7d8d70f8b2fc284b1751ffb35c7b6a6302b5192f8ab4ddd80"}, 36 | "nanoid": {:hex, :nanoid, "2.0.5", "1d2948d8967ef2d948a58c3fef02385040bd9823fc6394bd604b8d98e5516b22", [:mix], [], "hexpm", "956e8876321104da72aa48770539ff26b36b744cd26753ec8e7a8a37e53d5f58"}, 37 | "nimble_options": {:hex, :nimble_options, "0.5.2", "42703307b924880f8c08d97719da7472673391905f528259915782bb346e0a1b", [:mix], [], "hexpm", "4da7f904b915fd71db549bcdc25f8d56f378ef7ae07dc1d372cbe72ba950dce0"}, 38 | "phoenix": {:hex, :phoenix, "1.6.16", "e5bdd18c7a06da5852a25c7befb72246de4ddc289182285f8685a40b7b5f5451", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e15989ff34f670a96b95ef6d1d25bad0d9c50df5df40b671d8f4a669e050ac39"}, 39 | "phoenix_html": {:hex, :phoenix_html, "3.3.0", "bf451c71ebdaac8d2f40d3b703435e819ccfbb9ff243140ca3bd10c155f134cc", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "272c5c1533499f0132309936c619186480bafcc2246588f99a69ce85095556ef"}, 40 | "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.7.2", "97cc4ff2dba1ebe504db72cb45098cb8e91f11160528b980bd282cc45c73b29c", [: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]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18.3", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "0e5fdf063c7a3b620c566a30fcf68b7ee02e5e46fe48ee46a6ec3ba382dc05b7"}, 41 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"}, 42 | "phoenix_live_view": {:hex, :phoenix_live_view, "0.18.15", "58137e648fca9da56d6e931c9c3001f895ff090291052035f395bc958b82f1a5", [:mix], [{: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", [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]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "888dd8ea986bebbda741acc65aef788c384d13db91fea416461b2e96aa06a193"}, 43 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, 44 | "phoenix_template": {:hex, :phoenix_template, "1.0.1", "85f79e3ad1b0180abb43f9725973e3b8c2c3354a87245f91431eec60553ed3ef", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "157dc078f6226334c91cb32c1865bf3911686f8bcd6bcff86736f6253e6993ee"}, 45 | "phoenix_view": {:hex, :phoenix_view, "2.0.2", "6bd4d2fd595ef80d33b439ede6a19326b78f0f1d8d62b9a318e3d9c1af351098", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "a929e7230ea5c7ee0e149ffcf44ce7cf7f4b6d2bfe1752dd7c084cdff152d36f"}, 46 | "picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"}, 47 | "plug": {:hex, :plug, "1.14.0", "ba4f558468f69cbd9f6b356d25443d0b796fbdc887e03fa89001384a9cac638f", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bf020432c7d4feb7b3af16a0c2701455cbbbb95e5b6866132cb09eb0c29adc14"}, 48 | "plug_cowboy": {:hex, :plug_cowboy, "2.6.0", "d1cf12ff96a1ca4f52207c5271a6c351a4733f413803488d75b70ccf44aebec2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "073cf20b753ce6682ed72905cd62a2d4bd9bad1bf9f7feb02a1b8e525bd94fa6"}, 49 | "plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"}, 50 | "postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"}, 51 | "providers": {:hex, :providers, "1.8.1", "70b4197869514344a8a60e2b2a4ef41ca03def43cfb1712ecf076a0f3c62f083", [:rebar3], [{:getopt, "1.0.1", [hex: :getopt, repo: "hexpm", optional: false]}], "hexpm", "e45745ade9c476a9a469ea0840e418ab19360dc44f01a233304e118a44486ba0"}, 52 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 53 | "slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"}, 54 | "sourceror": {:hex, :sourceror, "0.12.1", "239a98ae2ed191528d64e079eaa355f6f1f69318dbb51796e08497dd3b24d10e", [:mix], [], "hexpm", "b5e310385813d0c791e8a481516654a4e10b7a0fdb55b4fc4ef915fbc0899b8f"}, 55 | "spark": {:hex, :spark, "0.4.5", "fbb6e7b30ca38b75a2af4b2c3d167d038c9f8f35ea6d0a014dad6256089f0d62", [:mix], [{:nimble_options, "~> 0.5", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "948f4370521d04c4f99c46b3c4bb781fa00e1bc5172e76540a37eb1f2b5e75a8"}, 56 | "stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"}, 57 | "tailwind": {:hex, :tailwind, "0.1.10", "21ed80ae1f411f747ee513470578acaaa1d0eb40170005350c5b0b6d07e2d624", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "e0fc474dfa8ed7a4573851ac69c5fd3ca70fbb0a5bada574d1d657ebc6f2f1f1"}, 58 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 59 | "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, 60 | "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, 61 | "typable": {:hex, :typable, "0.3.0", "0431e121d124cd26f312123e313d2689b9a5322b15add65d424c07779eaa3ca1", [:mix], [], "hexpm", "880a0797752da1a4c508ac48f94711e04c86156f498065a83d160eef945858f8"}, 62 | } 63 | --------------------------------------------------------------------------------