├── .formatter.exs ├── .gitignore ├── README.md ├── assets ├── css │ └── app.css ├── js │ └── app.js ├── tailwind.config.js └── vendor │ └── topbar.js ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── runtime.exs └── test.exs ├── lib ├── twitter.ex ├── twitter │ ├── accounts │ │ ├── accounts.ex │ │ ├── registry.ex │ │ ├── resources │ │ │ ├── friend_link.ex │ │ │ ├── token.ex │ │ │ └── user.ex │ │ └── secrets.ex │ ├── application.ex │ ├── mailer.ex │ ├── repo.ex │ └── tweets │ │ ├── registry.ex │ │ ├── resources │ │ ├── like.ex │ │ └── tweet.ex │ │ └── tweets.ex ├── twitter_web.ex └── twitter_web │ ├── auth_overrides.ex │ ├── components │ ├── core_components.ex │ ├── layouts.ex │ └── layouts │ │ ├── app.html.heex │ │ └── root.html.heex │ ├── controllers │ ├── auth_controller.ex │ ├── error_html.ex │ ├── error_json.ex │ ├── page_controller.ex │ ├── page_html.ex │ └── page_html │ │ └── home.html.heex │ ├── endpoint.ex │ ├── gettext.ex │ ├── live │ └── tweet_live │ │ ├── form_component.ex │ │ ├── index.ex │ │ ├── show.ex │ │ └── show.html.heex │ ├── live_user_auth.ex │ ├── router.ex │ └── telemetry.ex ├── mix.exs ├── mix.lock ├── priv ├── gettext │ ├── en │ │ └── LC_MESSAGES │ │ │ └── errors.po │ └── errors.pot ├── repo │ ├── migrations │ │ ├── .formatter.exs │ │ ├── 20230120180920_install_citext_extension.exs │ │ └── 20230120180921_migrate_resources1.exs │ └── seeds.exs ├── resource_snapshots │ ├── extensions.json │ └── repo │ │ ├── friend_links │ │ └── 20230120180921.json │ │ ├── likes │ │ └── 20230120180921.json │ │ ├── tokens │ │ └── 20230120180921.json │ │ ├── tweets │ │ └── 20230120180921.json │ │ └── users │ │ └── 20230120180921.json └── static │ ├── favicon.ico │ ├── images │ └── phoenix.png │ └── robots.txt └── test ├── support ├── conn_case.ex ├── data_case.ex └── fixtures │ └── tweets_fixtures.ex ├── test_helper.exs ├── twitter └── tweets_test.exs └── twitter_web ├── controllers ├── error_html_test.exs ├── error_json_test.exs └── page_controller_test.exs └── live └── tweet_live_test.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [ 3 | :ecto, 4 | :ecto_sql, 5 | :phoenix, 6 | :spark, 7 | :ash, 8 | :ash_postgres, 9 | :ash_authentication, 10 | :ash_authentication_phoenix, 11 | :ash_phoenix, 12 | :ash_admin 13 | ], 14 | subdirectories: ["priv/*/migrations"], 15 | plugins: [Phoenix.LiveView.HTMLFormatter, Spark.Formatter], 16 | inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] 17 | ] 18 | -------------------------------------------------------------------------------- /.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 | twitter-*.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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Twitter 2 | 3 | This is a tiny example app made to show a few features of Ash. These features include: 4 | 5 | * Core behavior like attributes/relationships 6 | * Advanced core tools like calculations & aggregates 7 | * AshAuthentication 8 | * AshPhoenix tools to integrate with Phoenix 9 | * AshAdmin 10 | * PubSub Notifiers 11 | 12 | This was made for a youtube video with LiveView Mastery and as such there aren't really any docs here. The idea is to provide the code for perusal, but I suggest watching the video (which should be out very soon!) 13 | 14 | To start your Phoenix server: 15 | 16 | * Install dependencies with `mix deps.get` 17 | * Create and migrate your database with `mix ecto.setup` 18 | * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` 19 | 20 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 21 | 22 | Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). 23 | 24 | ## Learn more 25 | 26 | * Official website: https://www.phoenixframework.org/ 27 | * Guides: https://hexdocs.pm/phoenix/overview.html 28 | * Docs: https://hexdocs.pm/phoenix 29 | * Forum: https://elixirforum.com/c/phoenix-forum 30 | * Source: https://github.com/phoenixframework/phoenix 31 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | 5 | /* This file is for your main application CSS */ 6 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // If you want to use Phoenix channels, run `mix help phx.gen.channel` 2 | // to get started and then uncomment the line below. 3 | // import "./user_socket.js" 4 | 5 | // You can include dependencies in two ways. 6 | // 7 | // The simplest option is to put them in assets/vendor and 8 | // import them using relative paths: 9 | // 10 | // import "../vendor/some-package.js" 11 | // 12 | // Alternatively, you can `npm install some-package --prefix assets` and import 13 | // them using a path starting with the package name: 14 | // 15 | // import "some-package" 16 | // 17 | 18 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 19 | import "phoenix_html" 20 | // Establish Phoenix Socket and LiveView configuration. 21 | import {Socket} from "phoenix" 22 | import {LiveSocket} from "phoenix_live_view" 23 | import topbar from "../vendor/topbar" 24 | 25 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 26 | let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) 27 | 28 | // Show progress bar on live navigation and form submits 29 | topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) 30 | window.addEventListener("phx:page-loading-start", info => topbar.delayedShow(200)) 31 | window.addEventListener("phx:page-loading-stop", info => topbar.hide()) 32 | 33 | // connect if there are any LiveViews on the page 34 | liveSocket.connect() 35 | 36 | // expose liveSocket on window for web console debug logs and latency simulation: 37 | // >> liveSocket.enableDebug() 38 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 39 | // >> liveSocket.disableLatencySim() 40 | window.liveSocket = liveSocket 41 | 42 | -------------------------------------------------------------------------------- /assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // See the Tailwind configuration guide for advanced usage 2 | // https://tailwindcss.com/docs/configuration 3 | 4 | const 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 | brand: "#FD4F00", 17 | } 18 | }, 19 | }, 20 | plugins: [ 21 | require("@tailwindcss/forms"), 22 | plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])), 23 | plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), 24 | plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), 25 | plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])) 26 | ] 27 | } -------------------------------------------------------------------------------- /assets/vendor/topbar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license MIT 3 | * topbar 1.0.0, 2021-01-06 4 | * Modifications: 5 | * - add delayedShow(time) (2022-09-21) 6 | * http://buunguyen.github.io/topbar 7 | * Copyright (c) 2021 Buu Nguyen 8 | */ 9 | (function (window, document) { 10 | "use strict"; 11 | 12 | // https://gist.github.com/paulirish/1579671 13 | (function () { 14 | var lastTime = 0; 15 | var vendors = ["ms", "moz", "webkit", "o"]; 16 | for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { 17 | window.requestAnimationFrame = 18 | window[vendors[x] + "RequestAnimationFrame"]; 19 | window.cancelAnimationFrame = 20 | window[vendors[x] + "CancelAnimationFrame"] || 21 | window[vendors[x] + "CancelRequestAnimationFrame"]; 22 | } 23 | if (!window.requestAnimationFrame) 24 | window.requestAnimationFrame = function (callback, element) { 25 | var currTime = new Date().getTime(); 26 | var timeToCall = Math.max(0, 16 - (currTime - lastTime)); 27 | var id = window.setTimeout(function () { 28 | callback(currTime + timeToCall); 29 | }, timeToCall); 30 | lastTime = currTime + timeToCall; 31 | return id; 32 | }; 33 | if (!window.cancelAnimationFrame) 34 | window.cancelAnimationFrame = function (id) { 35 | clearTimeout(id); 36 | }; 37 | })(); 38 | 39 | var canvas, 40 | currentProgress, 41 | showing, 42 | progressTimerId = null, 43 | fadeTimerId = null, 44 | delayTimerId = null, 45 | addEvent = function (elem, type, handler) { 46 | if (elem.addEventListener) elem.addEventListener(type, handler, false); 47 | else if (elem.attachEvent) elem.attachEvent("on" + type, handler); 48 | else elem["on" + type] = handler; 49 | }, 50 | options = { 51 | autoRun: true, 52 | barThickness: 3, 53 | barColors: { 54 | 0: "rgba(26, 188, 156, .9)", 55 | ".25": "rgba(52, 152, 219, .9)", 56 | ".50": "rgba(241, 196, 15, .9)", 57 | ".75": "rgba(230, 126, 34, .9)", 58 | "1.0": "rgba(211, 84, 0, .9)", 59 | }, 60 | shadowBlur: 10, 61 | shadowColor: "rgba(0, 0, 0, .6)", 62 | className: null, 63 | }, 64 | repaint = function () { 65 | canvas.width = window.innerWidth; 66 | canvas.height = options.barThickness * 5; // need space for shadow 67 | 68 | var ctx = canvas.getContext("2d"); 69 | ctx.shadowBlur = options.shadowBlur; 70 | ctx.shadowColor = options.shadowColor; 71 | 72 | var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); 73 | for (var stop in options.barColors) 74 | lineGradient.addColorStop(stop, options.barColors[stop]); 75 | ctx.lineWidth = options.barThickness; 76 | ctx.beginPath(); 77 | ctx.moveTo(0, options.barThickness / 2); 78 | ctx.lineTo( 79 | Math.ceil(currentProgress * canvas.width), 80 | options.barThickness / 2 81 | ); 82 | ctx.strokeStyle = lineGradient; 83 | ctx.stroke(); 84 | }, 85 | createCanvas = function () { 86 | canvas = document.createElement("canvas"); 87 | var style = canvas.style; 88 | style.position = "fixed"; 89 | style.top = style.left = style.right = style.margin = style.padding = 0; 90 | style.zIndex = 100001; 91 | style.display = "none"; 92 | if (options.className) canvas.classList.add(options.className); 93 | document.body.appendChild(canvas); 94 | addEvent(window, "resize", repaint); 95 | }, 96 | topbar = { 97 | config: function (opts) { 98 | for (var key in opts) 99 | if (options.hasOwnProperty(key)) options[key] = opts[key]; 100 | }, 101 | delayedShow: function(time) { 102 | if (showing) return; 103 | if (delayTimerId) return; 104 | delayTimerId = setTimeout(() => topbar.show(), time); 105 | }, 106 | show: function () { 107 | if (showing) return; 108 | showing = true; 109 | if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); 110 | if (!canvas) createCanvas(); 111 | canvas.style.opacity = 1; 112 | canvas.style.display = "block"; 113 | topbar.progress(0); 114 | if (options.autoRun) { 115 | (function loop() { 116 | progressTimerId = window.requestAnimationFrame(loop); 117 | topbar.progress( 118 | "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) 119 | ); 120 | })(); 121 | } 122 | }, 123 | progress: function (to) { 124 | if (typeof to === "undefined") return currentProgress; 125 | if (typeof to === "string") { 126 | to = 127 | (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 128 | ? currentProgress 129 | : 0) + parseFloat(to); 130 | } 131 | currentProgress = to > 1 ? 1 : to; 132 | repaint(); 133 | return currentProgress; 134 | }, 135 | hide: function () { 136 | clearTimeout(delayTimerId); 137 | delayTimerId = null; 138 | if (!showing) return; 139 | showing = false; 140 | if (progressTimerId != null) { 141 | window.cancelAnimationFrame(progressTimerId); 142 | progressTimerId = null; 143 | } 144 | (function loop() { 145 | if (topbar.progress("+.1") >= 1) { 146 | canvas.style.opacity -= 0.05; 147 | if (canvas.style.opacity <= 0.05) { 148 | canvas.style.display = "none"; 149 | fadeTimerId = null; 150 | return; 151 | } 152 | } 153 | fadeTimerId = window.requestAnimationFrame(loop); 154 | })(); 155 | }, 156 | }; 157 | 158 | if (typeof module === "object" && typeof module.exports === "object") { 159 | module.exports = topbar; 160 | } else if (typeof define === "function" && define.amd) { 161 | define(function () { 162 | return topbar; 163 | }); 164 | } else { 165 | this.topbar = topbar; 166 | } 167 | }.call(this, window, document)); 168 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | config :ash, :use_all_identities_in_manage_relationship?, false 11 | 12 | config :twitter, 13 | ecto_repos: [Twitter.Repo] 14 | 15 | config :twitter, ash_apis: [Twitter.Accounts, Twitter.Tweets] 16 | 17 | # Configures the endpoint 18 | config :twitter, TwitterWeb.Endpoint, 19 | url: [host: "localhost"], 20 | render_errors: [ 21 | formats: [html: TwitterWeb.ErrorHTML, json: TwitterWeb.ErrorJSON], 22 | layout: false 23 | ], 24 | pubsub_server: Twitter.PubSub, 25 | live_view: [signing_salt: "DIv4zwpo"] 26 | 27 | # Configures the mailer 28 | # 29 | # By default it uses the "Local" adapter which stores the emails 30 | # locally. You can see the emails in your browser, at "/dev/mailbox". 31 | # 32 | # For production it's recommended to configure a different adapter 33 | # at the `config/runtime.exs`. 34 | config :twitter, Twitter.Mailer, adapter: Swoosh.Adapters.Local 35 | 36 | # Configure esbuild (the version is required) 37 | config :esbuild, 38 | version: "0.14.41", 39 | default: [ 40 | args: 41 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), 42 | cd: Path.expand("../assets", __DIR__), 43 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 44 | ] 45 | 46 | # Configure tailwind (the version is required) 47 | config :tailwind, 48 | version: "3.1.8", 49 | default: [ 50 | args: ~w( 51 | --config=tailwind.config.js 52 | --input=css/app.css 53 | --output=../priv/static/assets/app.css 54 | ), 55 | cd: Path.expand("../assets", __DIR__) 56 | ] 57 | 58 | # Configures Elixir's Logger 59 | config :logger, :console, 60 | format: "$time $metadata[$level] $message\n", 61 | metadata: [:request_id] 62 | 63 | # Use Jason for JSON parsing in Phoenix 64 | config :phoenix, :json_library, Jason 65 | 66 | # Import environment specific config. This must remain at the bottom 67 | # of this file so it overrides the configuration defined above. 68 | import_config "#{config_env()}.exs" 69 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | config :twitter, Twitter.Repo, 5 | username: "postgres", 6 | password: "postgres", 7 | hostname: "localhost", 8 | database: "twitter_dev", 9 | stacktrace: true, 10 | show_sensitive_data_on_connection_error: true, 11 | pool_size: 10 12 | 13 | secret_key_base = "waWRr0qfV6sfabi59OizFKEzSYNJ1DhSUTkQ6H5wRYTn81NukUDI5r7zHWQ9Lgsr" 14 | 15 | # For development, we disable any cache and enable 16 | # debugging and code reloading. 17 | # 18 | # The watchers configuration can be used to run external 19 | # watchers to your application. For example, we use it 20 | # with esbuild to bundle .js and .css sources. 21 | config :twitter, TwitterWeb.Endpoint, 22 | # Binding to loopback ipv4 address prevents access from other machines. 23 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 24 | http: [ip: {127, 0, 0, 1}, port: 4000], 25 | check_origin: false, 26 | code_reloader: true, 27 | debug_errors: true, 28 | secret_key_base: secret_key_base, 29 | watchers: [ 30 | esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, 31 | tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} 32 | ] 33 | 34 | config :ash, :pub_sub, debug?: true 35 | 36 | config :twitter, token_signing_secret: secret_key_base 37 | 38 | # ## SSL Support 39 | # 40 | # In order to use HTTPS in development, a self-signed 41 | # certificate can be generated by running the following 42 | # Mix task: 43 | # 44 | # mix phx.gen.cert 45 | # 46 | # Run `mix help phx.gen.cert` for more information. 47 | # 48 | # The `http:` config above can be replaced with: 49 | # 50 | # https: [ 51 | # port: 4001, 52 | # cipher_suite: :strong, 53 | # keyfile: "priv/cert/selfsigned_key.pem", 54 | # certfile: "priv/cert/selfsigned.pem" 55 | # ], 56 | # 57 | # If desired, both `http:` and `https:` keys can be 58 | # configured to run both http and https servers on 59 | # different ports. 60 | 61 | # Watch static and templates for browser reloading. 62 | config :twitter, TwitterWeb.Endpoint, 63 | live_reload: [ 64 | patterns: [ 65 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 66 | ~r"priv/gettext/.*(po)$", 67 | ~r"lib/twitter_web/(live|views)/.*(ex)$", 68 | ~r"lib/twitter_web/templates/.*(eex)$" 69 | ] 70 | ] 71 | 72 | # Enable dev routes for dashboard and mailbox 73 | config :twitter, dev_routes: true 74 | 75 | # Do not include metadata nor timestamps in development logs 76 | config :logger, :console, format: "[$level] $message\n" 77 | 78 | # Set a higher stacktrace during development. Avoid configuring such 79 | # in production as building large stacktraces may be expensive. 80 | config :phoenix, :stacktrace_depth, 20 81 | 82 | # Initialize plugs at runtime for faster development compilation 83 | config :phoenix, :plug_init_mode, :runtime 84 | 85 | # Disable swoosh api client as it is only required for production adapters. 86 | config :swoosh, :api_client, false 87 | -------------------------------------------------------------------------------- /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 :twitter, TwitterWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" 13 | 14 | # Configures Swoosh API Client 15 | config :swoosh, :api_client, Twitter.Finch 16 | 17 | # Do not print debug messages in production 18 | config :logger, level: :info 19 | 20 | # Runtime production configuration, including reading 21 | # of environment variables, is done on config/runtime.exs. 22 | -------------------------------------------------------------------------------- /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/twitter 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 :twitter, TwitterWeb.Endpoint, server: true 21 | end 22 | 23 | if config_env() == :prod do 24 | database_url = 25 | System.get_env("DATABASE_URL") || 26 | raise """ 27 | environment variable DATABASE_URL is missing. 28 | For example: ecto://USER:PASS@HOST/DATABASE 29 | """ 30 | 31 | maybe_ipv6 = if System.get_env("ECTO_IPV6"), do: [:inet6], else: [] 32 | 33 | config :twitter, Twitter.Repo, 34 | # ssl: true, 35 | url: database_url, 36 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), 37 | socket_options: maybe_ipv6 38 | 39 | # The secret key base is used to sign/encrypt cookies and other secrets. 40 | # A default value is used in config/dev.exs and config/test.exs but you 41 | # want to use a different value for prod and you most likely don't want 42 | # to check this value into version control, so we use an environment 43 | # variable instead. 44 | secret_key_base = 45 | System.get_env("SECRET_KEY_BASE") || 46 | raise """ 47 | environment variable SECRET_KEY_BASE is missing. 48 | You can generate one by calling: mix phx.gen.secret 49 | """ 50 | 51 | config :twitter, 52 | token_signing_secret: secret_key_base 53 | 54 | host = System.get_env("PHX_HOST") || "example.com" 55 | port = String.to_integer(System.get_env("PORT") || "4000") 56 | 57 | config :twitter, TwitterWeb.Endpoint, 58 | url: [host: host, port: 443, scheme: "https"], 59 | http: [ 60 | # Enable IPv6 and bind on all interfaces. 61 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 62 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html 63 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 64 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 65 | port: port 66 | ], 67 | secret_key_base: secret_key_base 68 | 69 | # ## SSL Support 70 | # 71 | # To get SSL working, you will need to add the `https` key 72 | # to your endpoint configuration: 73 | # 74 | # config :twitter, TwitterWeb.Endpoint, 75 | # https: [ 76 | # ..., 77 | # port: 443, 78 | # cipher_suite: :strong, 79 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 80 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 81 | # ] 82 | # 83 | # The `cipher_suite` is set to `:strong` to support only the 84 | # latest and more secure SSL ciphers. This means old browsers 85 | # and clients may not be supported. You can set it to 86 | # `:compatible` for wider support. 87 | # 88 | # `:keyfile` and `:certfile` expect an absolute path to the key 89 | # and cert in disk or a relative path inside priv, for example 90 | # "priv/ssl/server.key". For all supported SSL configuration 91 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 92 | # 93 | # We also recommend setting `force_ssl` in your endpoint, ensuring 94 | # no data is ever sent via http, always redirecting to https: 95 | # 96 | # config :twitter, TwitterWeb.Endpoint, 97 | # force_ssl: [hsts: true] 98 | # 99 | # Check `Plug.SSL` for all available options in `force_ssl`. 100 | 101 | # ## Configuring the mailer 102 | # 103 | # In production you need to configure the mailer to use a different adapter. 104 | # Also, you may need to configure the Swoosh API client of your choice if you 105 | # are not using SMTP. Here is an example of the configuration: 106 | # 107 | # config :twitter, Twitter.Mailer, 108 | # adapter: Swoosh.Adapters.Mailgun, 109 | # api_key: System.get_env("MAILGUN_API_KEY"), 110 | # domain: System.get_env("MAILGUN_DOMAIN") 111 | # 112 | # For this example you need include a HTTP client required by Swoosh API client. 113 | # Swoosh supports Hackney and Finch out of the box: 114 | # 115 | # config :swoosh, :api_client, Swoosh.ApiClient.Hackney 116 | # 117 | # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. 118 | end 119 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | # 5 | # The MIX_TEST_PARTITION environment variable can be used 6 | # to provide built-in test partitioning in CI environment. 7 | # Run `mix help test` for more information. 8 | config :twitter, Twitter.Repo, 9 | username: "postgres", 10 | password: "postgres", 11 | hostname: "localhost", 12 | database: "twitter_test#{System.get_env("MIX_TEST_PARTITION")}", 13 | pool: Ecto.Adapters.SQL.Sandbox, 14 | pool_size: 10 15 | 16 | secret_key_base = "IJnp77YtAVszKAGuZrGxZ8dvmYiRHjeW9rSVgr/MMEtwvMLas3M7NcdLgby4rZ1N" 17 | 18 | # We don't run a server during test. If one is required, 19 | # you can enable the server option below. 20 | config :twitter, TwitterWeb.Endpoint, 21 | http: [ip: {127, 0, 0, 1}, port: 4002], 22 | secret_key_base: secret_key_base, 23 | server: false 24 | 25 | config :twitter, token_signing_secret: secret_key_base 26 | 27 | # In test we don't send emails. 28 | config :twitter, Twitter.Mailer, adapter: Swoosh.Adapters.Test 29 | 30 | # Disable swoosh api client as it is only required for production adapters. 31 | config :swoosh, :api_client, false 32 | 33 | # Print only warnings and errors during test 34 | config :logger, level: :warning 35 | 36 | # Initialize plugs at runtime for faster test compilation 37 | config :phoenix, :plug_init_mode, :runtime 38 | -------------------------------------------------------------------------------- /lib/twitter.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitter do 2 | @moduledoc """ 3 | Twitter keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /lib/twitter/accounts/accounts.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitter.Accounts do 2 | use Ash.Api, extensions: [AshAdmin.Api] 3 | 4 | admin do 5 | show? true 6 | end 7 | 8 | resources do 9 | registry Twitter.Accounts.Registry 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/twitter/accounts/registry.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitter.Accounts.Registry do 2 | use Ash.Registry, 3 | extensions: Ash.Registry.ResourceValidations 4 | 5 | entries do 6 | entry Twitter.Accounts.User 7 | entry Twitter.Accounts.Token 8 | entry Twitter.Accounts.FriendLink 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/twitter/accounts/resources/friend_link.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitter.Accounts.FriendLink do 2 | use Ash.Resource, 3 | data_layer: AshPostgres.DataLayer 4 | 5 | actions do 6 | defaults [:read, :destroy] 7 | 8 | create :create do 9 | primary? true 10 | upsert? true 11 | upsert_identity :unique_link 12 | end 13 | end 14 | 15 | identities do 16 | identity :unique_link, [:source_user_id, :destination_user_id] 17 | end 18 | 19 | code_interface do 20 | define_for Twitter.Accounts 21 | define :create, args: [:source_user_id, :destination_user_id] 22 | end 23 | 24 | postgres do 25 | table "friend_links" 26 | repo Twitter.Repo 27 | end 28 | 29 | attributes do 30 | uuid_primary_key :id 31 | 32 | attribute :status, :atom do 33 | constraints one_of: [:approved, :ignored, :pending] 34 | default :pending 35 | allow_nil? false 36 | end 37 | end 38 | 39 | relationships do 40 | belongs_to :source_user, Twitter.Accounts.User do 41 | attribute_writable? true 42 | allow_nil? false 43 | end 44 | 45 | belongs_to :destination_user, Twitter.Accounts.User do 46 | attribute_writable? true 47 | allow_nil? false 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/twitter/accounts/resources/token.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitter.Accounts.Token do 2 | use Ash.Resource, 3 | data_layer: AshPostgres.DataLayer, 4 | extensions: [AshAuthentication.TokenResource] 5 | 6 | actions do 7 | defaults [:destroy] 8 | end 9 | 10 | code_interface do 11 | define_for Twitter.Accounts 12 | define :destroy 13 | end 14 | 15 | token do 16 | api Twitter.Accounts 17 | end 18 | 19 | postgres do 20 | table "tokens" 21 | repo Twitter.Repo 22 | end 23 | 24 | attributes do 25 | uuid_primary_key :id 26 | 27 | timestamps() 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/twitter/accounts/resources/user.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitter.Accounts.User do 2 | use Ash.Resource, 3 | data_layer: AshPostgres.DataLayer, 4 | extensions: [AshAuthentication, AshAdmin.Resource] 5 | 6 | admin do 7 | actor? true 8 | end 9 | 10 | authentication do 11 | api Twitter.Accounts 12 | 13 | strategies do 14 | password :password do 15 | identity_field :email 16 | end 17 | end 18 | 19 | tokens do 20 | enabled? true 21 | require_token_presence_for_authentication? true 22 | signing_secret Twitter.Accounts.Secrets 23 | store_all_tokens? true 24 | token_resource Twitter.Accounts.Token 25 | end 26 | end 27 | 28 | code_interface do 29 | define_for Twitter.Accounts 30 | 31 | define :add_and_request_friend, args: [:destination_user_id] 32 | end 33 | 34 | actions do 35 | read :read do 36 | primary? true 37 | pagination offset?: true 38 | end 39 | 40 | update :add_and_request_friend do 41 | primary? true 42 | accept [] 43 | 44 | argument :destination_user_id, :uuid do 45 | allow_nil? false 46 | end 47 | 48 | manual fn changeset, _ -> 49 | with {:ok, destination_user_id} <- 50 | Ash.Changeset.fetch_argument(changeset, :destination_user_id), 51 | {:ok, _} <- 52 | Twitter.Accounts.FriendLink.create(changeset.data.id, destination_user_id, %{ 53 | status: :approved 54 | }), 55 | {:ok, _} <- 56 | Twitter.Accounts.FriendLink.create(destination_user_id, changeset.data.id) do 57 | {:ok, changeset.data} 58 | end 59 | end 60 | end 61 | end 62 | 63 | identities do 64 | identity :unique_email, [:email] 65 | end 66 | 67 | postgres do 68 | table "users" 69 | repo Twitter.Repo 70 | end 71 | 72 | attributes do 73 | uuid_primary_key :id 74 | 75 | attribute :email, :ci_string do 76 | allow_nil? false 77 | end 78 | 79 | attribute :hashed_password, :string do 80 | sensitive? true 81 | end 82 | 83 | timestamps() 84 | end 85 | 86 | relationships do 87 | many_to_many :my_friends, Twitter.Accounts.User do 88 | through Twitter.Accounts.FriendLink 89 | source_attribute_on_join_resource :source_user_id 90 | destination_attribute_on_join_resource :destination_user_id 91 | end 92 | 93 | many_to_many :friends_to_me, Twitter.Accounts.User do 94 | through Twitter.Accounts.FriendLink 95 | source_attribute_on_join_resource :destination_user_id 96 | destination_attribute_on_join_resource :source_user_id 97 | end 98 | 99 | has_many :pending_friend_requests, Twitter.Accounts.FriendLink do 100 | filter expr(status == :pending) 101 | destination_attribute :source_user_id 102 | end 103 | 104 | has_many :approved_friend_requests, Twitter.Accounts.FriendLink do 105 | filter expr(status == :approved) 106 | destination_attribute :source_user_id 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/twitter/accounts/secrets.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitter.Accounts.Secrets do 2 | @moduledoc "Secrets adapter for AshHq authentication" 3 | use AshAuthentication.Secret 4 | 5 | def secret_for([:authentication, :tokens, :signing_secret], Twitter.Accounts.User, _) do 6 | Application.fetch_env(:twitter, :token_signing_secret) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/twitter/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitter.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 | TwitterWeb.Telemetry, 13 | # Start the Ecto repository 14 | Twitter.Repo, 15 | # Start the PubSub system 16 | {Phoenix.PubSub, name: Twitter.PubSub}, 17 | # Start Finch 18 | {Finch, name: Twitter.Finch}, 19 | # Start the Endpoint (http/https) 20 | TwitterWeb.Endpoint 21 | # Start a worker by calling: Twitter.Worker.start_link(arg) 22 | # {Twitter.Worker, arg} 23 | ] 24 | 25 | # See https://hexdocs.pm/elixir/Supervisor.html 26 | # for other strategies and supported options 27 | opts = [strategy: :one_for_one, name: Twitter.Supervisor] 28 | Supervisor.start_link(children, opts) 29 | end 30 | 31 | # Tell Phoenix to update the endpoint configuration 32 | # whenever the application is updated. 33 | @impl true 34 | def config_change(changed, _new, removed) do 35 | TwitterWeb.Endpoint.config_change(changed, removed) 36 | :ok 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/twitter/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitter.Mailer do 2 | use Swoosh.Mailer, otp_app: :twitter 3 | end 4 | -------------------------------------------------------------------------------- /lib/twitter/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitter.Repo do 2 | use AshPostgres.Repo, 3 | otp_app: :twitter 4 | 5 | def installed_extensions do 6 | ["citext"] 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/twitter/tweets/registry.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitter.Tweets.Registry do 2 | use Ash.Registry 3 | 4 | entries do 5 | entry Twitter.Tweets.Tweet 6 | entry Twitter.Tweets.Like 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/twitter/tweets/resources/like.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitter.Tweets.Like do 2 | use Ash.Resource, 3 | data_layer: AshPostgres.DataLayer 4 | 5 | actions do 6 | defaults [:read, :destroy] 7 | 8 | create :like do 9 | upsert? true 10 | upsert_identity :unique_user_and_tweet 11 | 12 | argument :tweet_id, :uuid do 13 | allow_nil? false 14 | end 15 | 16 | change set_attribute(:tweet_id, arg(:tweet_id)) 17 | change relate_actor(:user) 18 | end 19 | end 20 | 21 | identities do 22 | identity :unique_user_and_tweet, [:user_id, :tweet_id] 23 | end 24 | 25 | code_interface do 26 | define_for Twitter.Tweets 27 | define :like, args: [:tweet_id] 28 | end 29 | 30 | postgres do 31 | table "likes" 32 | repo Twitter.Repo 33 | 34 | references do 35 | reference :tweet do 36 | on_delete :delete 37 | end 38 | 39 | reference :user do 40 | on_delete :delete 41 | end 42 | end 43 | end 44 | 45 | attributes do 46 | uuid_primary_key :id 47 | 48 | timestamps() 49 | end 50 | 51 | relationships do 52 | belongs_to :user, Twitter.Accounts.User do 53 | api Twitter.Accounts 54 | allow_nil? false 55 | end 56 | 57 | belongs_to :tweet, Twitter.Tweets.Tweet do 58 | api Twitter.Tweets 59 | allow_nil? false 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/twitter/tweets/resources/tweet.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitter.Tweets.Tweet do 2 | use Ash.Resource, 3 | data_layer: AshPostgres.DataLayer, 4 | notifiers: [ 5 | Ash.Notifier.PubSub 6 | ] 7 | 8 | require Ecto.Query 9 | 10 | actions do 11 | defaults [:read, :destroy] 12 | 13 | read :feed do 14 | description "Get the feed of tweets for a user" 15 | 16 | argument :user_id, :uuid do 17 | allow_nil? false 18 | end 19 | 20 | prepare build(sort: [inserted_at: :desc]) 21 | 22 | filter expr(visible_to(user_id: arg(:user_id))) 23 | end 24 | 25 | create :create do 26 | accept [:text] 27 | 28 | primary? true 29 | 30 | argument :public, :boolean do 31 | allow_nil? false 32 | default true 33 | end 34 | 35 | change fn changeset, _ -> 36 | if Ash.Changeset.get_argument(changeset, :public) do 37 | Ash.Changeset.force_change_attribute(changeset, :visibility, :public) 38 | else 39 | changeset 40 | end 41 | end 42 | 43 | change relate_actor(:author) 44 | end 45 | 46 | update :update do 47 | accept [:text] 48 | 49 | primary? true 50 | 51 | argument :public, :boolean do 52 | allow_nil? false 53 | default true 54 | end 55 | 56 | change fn changeset, _ -> 57 | if Ash.Changeset.get_argument(changeset, :public) do 58 | Ash.Changeset.force_change_attribute(changeset, :visibility, :public) 59 | else 60 | Ash.Changeset.force_change_attribute(changeset, :visibility, :friends) 61 | end 62 | end 63 | end 64 | 65 | update :like do 66 | accept [] 67 | 68 | manual fn changeset, %{actor: actor} -> 69 | with {:ok, _} <- Twitter.Tweets.Like.like(changeset.data.id, actor: actor) do 70 | {:ok, changeset.data} 71 | end 72 | end 73 | end 74 | 75 | update :dislike do 76 | accept [] 77 | 78 | manual fn changeset, %{actor: actor} -> 79 | like = 80 | Ecto.Query.from(like in Twitter.Tweets.Like, 81 | where: like.user_id == ^actor.id, 82 | where: like.tweet_id == ^changeset.data.id 83 | ) 84 | 85 | Twitter.Repo.delete_all(like) 86 | 87 | {:ok, changeset.data} 88 | end 89 | end 90 | end 91 | 92 | pub_sub do 93 | module TwitterWeb.Endpoint 94 | prefix "tweets" 95 | 96 | publish :like, ["liked", :id] 97 | publish :dislike, ["unliked", :id] 98 | publish :create, ["created"] 99 | end 100 | 101 | code_interface do 102 | define_for Twitter.Tweets 103 | define :feed, args: [:user_id] 104 | define :get, action: :read, get_by: [:id] 105 | define :like 106 | define :dislike 107 | define :destroy 108 | end 109 | 110 | attributes do 111 | uuid_primary_key :id 112 | 113 | attribute :text, :string do 114 | allow_nil? false 115 | constraints max_length: 255 116 | end 117 | 118 | attribute :visibility, :atom do 119 | constraints one_of: [:friends, :public] 120 | end 121 | 122 | timestamps() 123 | end 124 | 125 | relationships do 126 | belongs_to :author, Twitter.Accounts.User do 127 | api Twitter.Accounts 128 | allow_nil? false 129 | end 130 | end 131 | 132 | postgres do 133 | table "tweets" 134 | repo Twitter.Repo 135 | end 136 | 137 | relationships do 138 | has_many :likes, Twitter.Tweets.Like 139 | end 140 | 141 | calculations do 142 | calculate :liked_by_user, :boolean, expr(exists(likes, user_id == ^arg(:user_id))) do 143 | argument :user_id, :uuid do 144 | allow_nil? false 145 | end 146 | end 147 | 148 | calculate :visible_to, 149 | :boolean, 150 | expr( 151 | author_id == ^arg(:user_id) or visibility == :public or 152 | visible_as_friend(user_id: arg(:user_id)) 153 | ) do 154 | argument :user_id, :uuid do 155 | allow_nil? false 156 | end 157 | end 158 | 159 | calculate :visible_as_friend, 160 | :boolean, 161 | expr(exists(author.approved_friend_requests, destination_user_id == ^arg(:user_id))) do 162 | argument :user_id, :uuid do 163 | allow_nil? false 164 | end 165 | end 166 | end 167 | 168 | aggregates do 169 | first :author_email, :author, :email 170 | count :like_count, :likes 171 | end 172 | end 173 | -------------------------------------------------------------------------------- /lib/twitter/tweets/tweets.ex: -------------------------------------------------------------------------------- 1 | defmodule Twitter.Tweets do 2 | use Ash.Api, 3 | extensions: [AshAdmin.Api] 4 | 5 | admin do 6 | show? true 7 | end 8 | 9 | resources do 10 | registry Twitter.Tweets.Registry 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/twitter_web.ex: -------------------------------------------------------------------------------- 1 | defmodule TwitterWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, components, channels, and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use TwitterWeb, :controller 9 | use TwitterWeb, :html 10 | 11 | The definitions below will be executed for every controller, 12 | component, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define additional modules and import 17 | those modules here. 18 | """ 19 | 20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) 21 | 22 | def router do 23 | quote do 24 | use Phoenix.Router 25 | 26 | # Import common connection and controller functions to use in pipelines 27 | import Plug.Conn 28 | import Phoenix.Controller 29 | import Phoenix.LiveView.Router 30 | end 31 | end 32 | 33 | def channel do 34 | quote do 35 | use Phoenix.Channel 36 | end 37 | end 38 | 39 | def controller do 40 | quote do 41 | use Phoenix.Controller, 42 | namespace: TwitterWeb, 43 | formats: [:html, :json], 44 | layouts: [html: TwitterWeb.Layouts] 45 | 46 | import Plug.Conn 47 | import TwitterWeb.Gettext 48 | 49 | unquote(verified_routes()) 50 | end 51 | end 52 | 53 | def live_view do 54 | quote do 55 | use Phoenix.LiveView, 56 | layout: {TwitterWeb.Layouts, :app} 57 | 58 | unquote(html_helpers()) 59 | end 60 | end 61 | 62 | def live_component do 63 | quote do 64 | use Phoenix.LiveComponent 65 | 66 | unquote(html_helpers()) 67 | end 68 | end 69 | 70 | def html do 71 | quote do 72 | use Phoenix.Component 73 | 74 | # Import convenience functions from controllers 75 | import Phoenix.Controller, 76 | only: [get_csrf_token: 0, view_module: 1, view_template: 1] 77 | 78 | # Include general helpers for rendering HTML 79 | unquote(html_helpers()) 80 | end 81 | end 82 | 83 | defp html_helpers do 84 | quote do 85 | # HTML escaping functionality 86 | import Phoenix.HTML 87 | # Core UI components and translation 88 | import TwitterWeb.CoreComponents 89 | import TwitterWeb.Gettext 90 | 91 | # Shortcut for generating JS commands 92 | alias Phoenix.LiveView.JS 93 | 94 | # Routes generation with the ~p sigil 95 | unquote(verified_routes()) 96 | end 97 | end 98 | 99 | def verified_routes do 100 | quote do 101 | use Phoenix.VerifiedRoutes, 102 | endpoint: TwitterWeb.Endpoint, 103 | router: TwitterWeb.Router, 104 | statics: TwitterWeb.static_paths() 105 | end 106 | end 107 | 108 | @doc """ 109 | When used, dispatch to the appropriate controller/view/etc. 110 | """ 111 | defmacro __using__(which) when is_atom(which) do 112 | apply(__MODULE__, which, []) 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/twitter_web/auth_overrides.ex: -------------------------------------------------------------------------------- 1 | defmodule TwitterWeb.AuthOverrides do 2 | @moduledoc "UI overrides for authentication views" 3 | use AshAuthentication.Phoenix.Overrides 4 | 5 | override AshAuthentication.Phoenix.SignInLive do 6 | set :root_class, "grid h-screen place-items-center dark:bg-black" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/twitter_web/components/core_components.ex: -------------------------------------------------------------------------------- 1 | defmodule TwitterWeb.CoreComponents do 2 | @moduledoc """ 3 | Provides core UI components. 4 | 5 | The components in this module use Tailwind CSS, a utility-first CSS framework. 6 | See the [Tailwind CSS documentation](https://tailwindcss.com) to learn how to 7 | customize the generated components in this module. 8 | 9 | Icons are provided by [heroicons](https://heroicons.com), using the 10 | [heroicons_elixir](https://github.com/mveytsman/heroicons_elixir) project. 11 | """ 12 | use Phoenix.Component 13 | 14 | alias Phoenix.LiveView.JS 15 | import TwitterWeb.Gettext 16 | 17 | @doc """ 18 | Renders a modal. 19 | 20 | ## Examples 21 | 22 | <.modal id="confirm-modal"> 23 | Are you sure? 24 | <:confirm>OK 25 | <:cancel>Cancel 26 | 27 | 28 | JS commands may be passed to the `:on_cancel` and `on_confirm` attributes 29 | for the caller to react to each button press, for example: 30 | 31 | <.modal id="confirm" on_confirm={JS.push("delete")} on_cancel={JS.navigate(~p"/posts")}> 32 | Are you sure you? 33 | <:confirm>OK 34 | <:cancel>Cancel 35 | 36 | """ 37 | attr :id, :string, required: true 38 | attr :show, :boolean, default: false 39 | attr :on_cancel, JS, default: %JS{} 40 | attr :on_confirm, JS, default: %JS{} 41 | 42 | slot :inner_block, required: true 43 | slot :title 44 | slot :subtitle 45 | slot :confirm 46 | slot :cancel 47 | 48 | def modal(assigns) do 49 | ~H""" 50 |