├── .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 |
51 |
52 |
60 |
61 |
62 | <.focus_wrap
63 | id={"#{@id}-container"}
64 | phx-mounted={@show && show_modal(@id)}
65 | phx-window-keydown={hide_modal(@on_cancel, @id)}
66 | phx-key="escape"
67 | phx-click-away={hide_modal(@on_cancel, @id)}
68 | class="hidden relative rounded-2xl bg-white p-14 shadow-lg shadow-zinc-700/10 ring-1 ring-zinc-700/10 transition"
69 | >
70 |
71 |
77 |
78 |
79 |
80 |
81 |
89 | <%= render_slot(@inner_block) %>
90 |
91 | <.button
92 | :for={confirm <- @confirm}
93 | id={"#{@id}-confirm"}
94 | phx-click={@on_confirm}
95 | phx-disable-with
96 | class="py-2 px-3"
97 | >
98 | <%= render_slot(confirm) %>
99 |
100 | <.link
101 | :for={cancel <- @cancel}
102 | phx-click={hide_modal(@on_cancel, @id)}
103 | class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
104 | >
105 | <%= render_slot(cancel) %>
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 | """
115 | end
116 |
117 | @doc """
118 | Renders flash notices.
119 |
120 | ## Examples
121 |
122 | <.flash kind={:info} flash={@flash} />
123 | <.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!
124 | """
125 | attr :id, :string, default: "flash", doc: "the optional id of flash container"
126 | attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
127 | attr :title, :string, default: nil
128 | attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
129 | attr :autoshow, :boolean, default: true, doc: "whether to auto show the flash on mount"
130 | attr :close, :boolean, default: true, doc: "whether the flash can be closed"
131 | attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
132 |
133 | slot :inner_block, doc: "the optional inner block that renders the flash message"
134 |
135 | def flash(assigns) do
136 | ~H"""
137 | hide("#flash")}
142 | role="alert"
143 | class={[
144 | "fixed hidden top-2 right-2 w-80 sm:w-96 z-50 rounded-lg p-3 shadow-md shadow-zinc-900/5 ring-1",
145 | @kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900",
146 | @kind == :error && "bg-rose-50 p-3 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
147 | ]}
148 | {@rest}
149 | >
150 |
151 |
152 |
153 | <%= @title %>
154 |
155 |
<%= msg %>
156 |
162 |
163 |
164 |
165 | """
166 | end
167 |
168 | @doc """
169 | Renders a simple form.
170 |
171 | ## Examples
172 |
173 | <.simple_form :let={f} for={:user} phx-change="validate" phx-submit="save">
174 | <.input field={{f, :email}} label="Email"/>
175 | <.input field={{f, :username}} label="Username" />
176 | <:actions>
177 | <.button>Save
178 |
179 |
180 | """
181 | attr :for, :any, default: nil, doc: "the datastructure for the form"
182 | attr :as, :any, default: nil, doc: "the server side parameter to collect all input under"
183 |
184 | attr :rest, :global,
185 | include: ~w(autocomplete name rel action enctype method novalidate target),
186 | doc: "the arbitrary HTML attributes to apply to the form tag"
187 |
188 | slot :inner_block, required: true
189 | slot :actions, doc: "the slot for form actions, such as a submit button"
190 |
191 | def simple_form(assigns) do
192 | ~H"""
193 | <.form :let={f} for={@for} as={@as} {@rest}>
194 |
195 | <%= render_slot(@inner_block, f) %>
196 |
197 | <%= render_slot(action, f) %>
198 |
199 |
200 |
201 | """
202 | end
203 |
204 | @doc """
205 | Renders a button.
206 |
207 | ## Examples
208 |
209 | <.button>Send!
210 | <.button phx-click="go" class="ml-2">Send!
211 | """
212 | attr :type, :string, default: nil
213 | attr :class, :string, default: nil
214 | attr :rest, :global, include: ~w(disabled form name value)
215 |
216 | slot :inner_block, required: true
217 |
218 | def button(assigns) do
219 | ~H"""
220 |
229 | <%= render_slot(@inner_block) %>
230 |
231 | """
232 | end
233 |
234 | @doc """
235 | Renders an input with label and error messages.
236 |
237 | A `%Phoenix.HTML.Form{}` and field name may be passed to the input
238 | to build input names and error messages, or all the attributes and
239 | errors may be passed explicitly.
240 |
241 | ## Examples
242 |
243 | <.input field={{f, :email}} type="email" />
244 | <.input name="my-input" errors={["oh no!"]} />
245 | """
246 | attr :id, :any
247 | attr :name, :any
248 | attr :label, :string, default: nil
249 |
250 | attr :type, :string,
251 | default: "text",
252 | values: ~w(checkbox color date datetime-local email file hidden month number password
253 | range radio search select tel text textarea time url week)
254 |
255 | attr :value, :any
256 | attr :field, :any, doc: "a %Phoenix.HTML.Form{}/field name tuple, for example: {f, :email}"
257 | attr :errors, :list
258 | attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
259 | attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
260 | attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
261 | attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
262 | attr :rest, :global, include: ~w(autocomplete disabled form max maxlength min minlength
263 | pattern placeholder readonly required size step)
264 | slot :inner_block
265 |
266 | def input(%{field: {f, field}} = assigns) do
267 | assigns
268 | |> assign(field: nil)
269 | |> assign_new(:name, fn ->
270 | name = Phoenix.HTML.Form.input_name(f, field)
271 | if assigns.multiple, do: name <> "[]", else: name
272 | end)
273 | |> assign_new(:id, fn -> Phoenix.HTML.Form.input_id(f, field) end)
274 | |> assign_new(:value, fn -> Phoenix.HTML.Form.input_value(f, field) end)
275 | |> assign_new(:errors, fn -> translate_errors(f.errors || [], field) end)
276 | |> input()
277 | end
278 |
279 | def input(%{type: "checkbox"} = assigns) do
280 | assigns = assign_new(assigns, :checked, fn -> input_equals?(assigns.value, "true") end)
281 |
282 | ~H"""
283 |
284 |
285 |
294 | <%= @label %>
295 |
296 | """
297 | end
298 |
299 | def input(%{type: "select"} = assigns) do
300 | ~H"""
301 |
302 | <.label for={@id}><%= @label %>
303 |
310 | <%= @prompt %>
311 | <%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
312 |
313 | <.error :for={msg <- @errors}><%= msg %>
314 |
315 | """
316 | end
317 |
318 | def input(%{type: "textarea"} = assigns) do
319 | ~H"""
320 |
321 | <.label for={@id}><%= @label %>
322 |
335 | <.error :for={msg <- @errors}><%= msg %>
336 |
337 | """
338 | end
339 |
340 | def input(assigns) do
341 | ~H"""
342 |
343 | <.label for={@id}><%= @label %>
344 |
357 | <.error :for={msg <- @errors}><%= msg %>
358 |
359 | """
360 | end
361 |
362 | defp input_border([] = _errors),
363 | do: "border-zinc-300 focus:border-zinc-400 focus:ring-zinc-800/5"
364 |
365 | defp input_border([_ | _] = _errors),
366 | do: "border-rose-400 focus:border-rose-400 focus:ring-rose-400/10"
367 |
368 | @doc """
369 | Renders a label.
370 | """
371 | attr :for, :string, default: nil
372 | slot :inner_block, required: true
373 |
374 | def label(assigns) do
375 | ~H"""
376 |
377 | <%= render_slot(@inner_block) %>
378 |
379 | """
380 | end
381 |
382 | @doc """
383 | Generates a generic error message.
384 | """
385 | slot :inner_block, required: true
386 |
387 | def error(assigns) do
388 | ~H"""
389 |
390 |
391 | <%= render_slot(@inner_block) %>
392 |
393 | """
394 | end
395 |
396 | @doc """
397 | Renders a header with title.
398 | """
399 | attr :class, :string, default: nil
400 |
401 | slot :inner_block, required: true
402 | slot :subtitle
403 | slot :actions
404 |
405 | def header(assigns) do
406 | ~H"""
407 |
418 | """
419 | end
420 |
421 | @doc ~S"""
422 | Renders a table with generic styling.
423 |
424 | ## Examples
425 |
426 | <.table id="users" rows={@users}>
427 | <:col :let={user} label="id"><%= user.id %>
428 | <:col :let={user} label="username"><%= user.username %>
429 |
430 | """
431 | attr :id, :string, required: true
432 | attr :row_click, :any, default: nil
433 | attr :rows, :list, required: true
434 |
435 | slot :col, required: true do
436 | attr :label, :string
437 | end
438 |
439 | slot :action, doc: "the slot for showing user actions in the last table column"
440 |
441 | def table(assigns) do
442 | ~H"""
443 |
444 |
445 |
446 |
447 | <%= col[:label] %>
448 | <%= gettext("Actions") %>
449 |
450 |
451 |
452 |
457 |
462 |
463 |
464 |
465 |
466 |
467 |
468 | <%= render_slot(col, row) %>
469 |
470 |
471 |
472 |
473 |
474 |
478 | <%= render_slot(action, row) %>
479 |
480 |
481 |
482 |
483 |
484 |
485 |
486 | """
487 | end
488 |
489 | @doc """
490 | Renders a data list.
491 |
492 | ## Examples
493 |
494 | <.list>
495 | <:item title="Title"><%= @post.title %>
496 | <:item title="Views"><%= @post.views %>
497 |
498 | """
499 | slot :item, required: true do
500 | attr :title, :string, required: true
501 | end
502 |
503 | def list(assigns) do
504 | ~H"""
505 |
506 |
507 |
508 |
<%= item.title %>
509 | <%= render_slot(item) %>
510 |
511 |
512 |
513 | """
514 | end
515 |
516 | @doc """
517 | Renders a back navigation link.
518 |
519 | ## Examples
520 |
521 | <.back navigate={~p"/posts"}>Back to posts
522 | """
523 | attr :navigate, :any, required: true
524 | slot :inner_block, required: true
525 |
526 | def back(assigns) do
527 | ~H"""
528 |
529 | <.link
530 | navigate={@navigate}
531 | class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
532 | >
533 |
534 | <%= render_slot(@inner_block) %>
535 |
536 |
537 | """
538 | end
539 |
540 | ## JS Commands
541 |
542 | def show(js \\ %JS{}, selector) do
543 | JS.show(js,
544 | to: selector,
545 | transition:
546 | {"transition-all transform ease-out duration-300",
547 | "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
548 | "opacity-100 translate-y-0 sm:scale-100"}
549 | )
550 | end
551 |
552 | def hide(js \\ %JS{}, selector) do
553 | JS.hide(js,
554 | to: selector,
555 | time: 200,
556 | transition:
557 | {"transition-all transform ease-in duration-200",
558 | "opacity-100 translate-y-0 sm:scale-100",
559 | "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
560 | )
561 | end
562 |
563 | def show_modal(js \\ %JS{}, id) when is_binary(id) do
564 | js
565 | |> JS.show(to: "##{id}")
566 | |> JS.show(
567 | to: "##{id}-bg",
568 | transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"}
569 | )
570 | |> show("##{id}-container")
571 | |> JS.focus_first(to: "##{id}-content")
572 | end
573 |
574 | def hide_modal(js \\ %JS{}, id) do
575 | js
576 | |> JS.hide(
577 | to: "##{id}-bg",
578 | transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"}
579 | )
580 | |> hide("##{id}-container")
581 | |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"})
582 | |> JS.pop_focus()
583 | end
584 |
585 | @doc """
586 | Translates an error message using gettext.
587 | """
588 | def translate_error({msg, opts}) do
589 | # When using gettext, we typically pass the strings we want
590 | # to translate as a static argument:
591 | #
592 | # # Translate "is invalid" in the "errors" domain
593 | # dgettext("errors", "is invalid")
594 | #
595 | # # Translate the number of files with plural rules
596 | # dngettext("errors", "1 file", "%{count} files", count)
597 | #
598 | # Because the error messages we show in our forms and APIs
599 | # are defined inside Ecto, we need to translate them dynamically.
600 | # This requires us to call the Gettext module passing our gettext
601 | # backend as first argument.
602 | #
603 | # Note we use the "errors" domain, which means translations
604 | # should be written to the errors.po file. The :count option is
605 | # set by Ecto and indicates we should also apply plural rules.
606 | if count = opts[:count] do
607 | Gettext.dngettext(TwitterWeb.Gettext, "errors", msg, msg, count, opts)
608 | else
609 | Gettext.dgettext(TwitterWeb.Gettext, "errors", msg, opts)
610 | end
611 | end
612 |
613 | @doc """
614 | Translates the errors for a field from a keyword list of errors.
615 | """
616 | def translate_errors(errors, field) when is_list(errors) do
617 | for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
618 | end
619 |
620 | defp input_equals?(val1, val2) do
621 | Phoenix.HTML.html_escape(val1) == Phoenix.HTML.html_escape(val2)
622 | end
623 | end
624 |
--------------------------------------------------------------------------------
/lib/twitter_web/components/layouts.ex:
--------------------------------------------------------------------------------
1 | defmodule TwitterWeb.Layouts do
2 | use TwitterWeb, :html
3 |
4 | embed_templates "layouts/*"
5 | end
6 |
--------------------------------------------------------------------------------
/lib/twitter_web/components/layouts/app.html.heex:
--------------------------------------------------------------------------------
1 |
38 |
39 |
40 | <.flash kind={:info} title="Success!" flash={@flash} />
41 | <.flash kind={:error} title="Error!" flash={@flash} />
42 | <.flash
43 | id="disconnected"
44 | kind={:error}
45 | title="We can't find the internet"
46 | close={false}
47 | autoshow={false}
48 | phx-disconnected={show("#disconnected")}
49 | phx-connected={hide("#disconnected")}
50 | >
51 | Attempting to reconnect
52 |
53 | <%= @inner_content %>
54 |
55 |
56 |
--------------------------------------------------------------------------------
/lib/twitter_web/components/layouts/root.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | <.live_title suffix=" · Phoenix Framework">
8 | <%= assigns[:page_title] || "Twitter" %>
9 |
10 |
11 |
13 |
14 |
15 | <%= case live_flash(@flash, :info) do %>
16 | <% nil -> %>
17 | <% flash -> %>
18 |
19 | <%= flash %>
20 |
21 | <% end %>
22 | <%= case live_flash(@flash, :error) do %>
23 | <% nil -> %>
24 | <% flash -> %>
25 |
26 | <%= flash %>
27 |
28 | <% end %>
29 | <%= @inner_content %>
30 |
31 |
32 |
--------------------------------------------------------------------------------
/lib/twitter_web/controllers/auth_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule TwitterWeb.AuthController do
2 | use TwitterWeb, :controller
3 | use AshAuthentication.Phoenix.Controller
4 |
5 | require Logger
6 |
7 | def success(conn, _activity, user, _token) do
8 | return_to = get_session(conn, :return_to) || "/"
9 |
10 | conn
11 | |> delete_session(:return_to)
12 | |> store_in_session(user)
13 | |> assign(:current_user, user)
14 | |> redirect(to: return_to)
15 | end
16 |
17 | def failure(
18 | conn,
19 | {:password, :sign_in},
20 | %AshAuthentication.Errors.AuthenticationFailed{}
21 | ) do
22 | conn
23 | |> put_flash(:error, "Username or password is incorrect")
24 | |> redirect(to: "/sign-in")
25 | end
26 |
27 | def failure(conn, activity, reason) do
28 | stacktrace =
29 | case reason do
30 | %{stacktrace: %{stacktrace: stacktrace}} -> stacktrace
31 | _ -> nil
32 | end
33 |
34 | Logger.error("""
35 | Something went wrong in authentication
36 |
37 | activity: #{inspect(activity)}
38 |
39 | reason: #{Exception.format(:error, reason, stacktrace || [])}
40 | """)
41 |
42 | conn
43 | |> put_flash(:error, "Username or password is incorrect")
44 | |> redirect(to: "/sign-in")
45 | end
46 |
47 | def sign_out(conn, _params) do
48 | return_to = get_session(conn, :return_to) || "/"
49 |
50 | token = Plug.Conn.get_session(conn, "user_token")
51 |
52 | if token do
53 | Twitter.Accounts.Token
54 | |> AshAuthentication.TokenResource.Actions.get_token(%{"token" => token})
55 | |> case do
56 | {:ok, [token]} ->
57 | Twitter.Accounts.Token.destroy!(token, authorize?: false)
58 |
59 | _ ->
60 | :ok
61 | end
62 | end
63 |
64 | conn
65 | |> clear_session()
66 | |> redirect(to: return_to)
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/lib/twitter_web/controllers/error_html.ex:
--------------------------------------------------------------------------------
1 | defmodule TwitterWeb.ErrorHTML do
2 | use TwitterWeb, :html
3 |
4 | # If you want to customize your error pages,
5 | # uncomment the embed_templates/1 call below
6 | # and add pages to the error directory:
7 | #
8 | # * lib/twitter_web/controllers/error/404.html.heex
9 | # * lib/twitter_web/controllers/error/500.html.heex
10 | #
11 | # embed_templates "error/*"
12 |
13 | # The default is to render a plain text page based on
14 | # the template name. For example, "404.html" becomes
15 | # "Not Found".
16 | def render(template, _assigns) do
17 | Phoenix.Controller.status_message_from_template(template)
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/twitter_web/controllers/error_json.ex:
--------------------------------------------------------------------------------
1 | defmodule TwitterWeb.ErrorJSON do
2 | # If you want to customize a particular status code,
3 | # you may add your own clauses, such as:
4 | #
5 | # def render("500.json", _assigns) do
6 | # %{errors: %{detail: "Internal Server Error"}}
7 | # end
8 |
9 | # By default, Phoenix returns the status message from
10 | # the template name. For example, "404.json" becomes
11 | # "Not Found".
12 | def render(template, _assigns) do
13 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/twitter_web/controllers/page_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule TwitterWeb.PageController do
2 | use TwitterWeb, :controller
3 |
4 | def home(conn, _params) do
5 | # The home page is often custom made,
6 | # so skip the default app layout.
7 | render(conn, :home, layout: false)
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/lib/twitter_web/controllers/page_html.ex:
--------------------------------------------------------------------------------
1 | defmodule TwitterWeb.PageHTML do
2 | use TwitterWeb, :html
3 |
4 | embed_templates "page_html/*"
5 | end
6 |
--------------------------------------------------------------------------------
/lib/twitter_web/controllers/page_html/home.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
14 |
18 |
23 |
28 |
33 |
38 |
39 |
40 |
41 |
42 |
43 |
47 |
48 |
49 | Phoenix Framework
50 |
51 | v1.7
52 |
53 |
54 |
55 | Peace of mind from prototype to production.
56 |
57 |
58 | Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale.
59 |
60 |
61 |
62 |
132 |
133 |
148 |
163 |
178 |
202 |
217 |
232 |
233 |
234 |
235 |
236 |
237 |
--------------------------------------------------------------------------------
/lib/twitter_web/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule TwitterWeb.Endpoint do
2 | use Phoenix.Endpoint, otp_app: :twitter
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: "_twitter_key",
10 | signing_salt: "1Z4aXoPW",
11 | same_site: "Lax"
12 | ]
13 |
14 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
15 |
16 | # Serve at "/" the static files from "priv/static" directory.
17 | #
18 | # You should set gzip to true if you are running phx.digest
19 | # when deploying your static files in production.
20 | plug Plug.Static,
21 | at: "/",
22 | from: :twitter,
23 | gzip: false,
24 | only: TwitterWeb.static_paths()
25 |
26 | # Code reloading can be explicitly enabled under the
27 | # :code_reloader configuration of your endpoint.
28 | if code_reloading? do
29 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
30 | plug Phoenix.LiveReloader
31 | plug Phoenix.CodeReloader
32 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :twitter
33 | end
34 |
35 | plug Phoenix.LiveDashboard.RequestLogger,
36 | param_key: "request_logger",
37 | cookie_key: "request_logger"
38 |
39 | plug Plug.RequestId
40 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
41 |
42 | plug Plug.Parsers,
43 | parsers: [:urlencoded, :multipart, :json],
44 | pass: ["*/*"],
45 | json_decoder: Phoenix.json_library()
46 |
47 | plug Plug.MethodOverride
48 | plug Plug.Head
49 | plug Plug.Session, @session_options
50 | plug TwitterWeb.Router
51 | end
52 |
--------------------------------------------------------------------------------
/lib/twitter_web/gettext.ex:
--------------------------------------------------------------------------------
1 | defmodule TwitterWeb.Gettext do
2 | @moduledoc """
3 | A module providing Internationalization with a gettext-based API.
4 |
5 | By using [Gettext](https://hexdocs.pm/gettext),
6 | your module gains a set of macros for translations, for example:
7 |
8 | import TwitterWeb.Gettext
9 |
10 | # Simple translation
11 | gettext("Here is the string to translate")
12 |
13 | # Plural translation
14 | ngettext("Here is the string to translate",
15 | "Here are the strings to translate",
16 | 3)
17 |
18 | # Domain-based translation
19 | dgettext("errors", "Here is the error message to translate")
20 |
21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
22 | """
23 | use Gettext, otp_app: :twitter
24 | end
25 |
--------------------------------------------------------------------------------
/lib/twitter_web/live/tweet_live/form_component.ex:
--------------------------------------------------------------------------------
1 | defmodule TwitterWeb.TweetLive.FormComponent do
2 | use TwitterWeb, :live_component
3 |
4 | @impl true
5 | def render(assigns) do
6 | ~H"""
7 |
8 | <.header>
9 | <%= @title %>
10 | <:subtitle>Share your thoughts
11 |
12 |
13 | <.simple_form
14 | :let={f}
15 | for={@form}
16 | id="tweet-form"
17 | phx-target={@myself}
18 | phx-change="validate"
19 | phx-submit="save"
20 | >
21 | <.input field={{f, :text}} type="textarea" label="Text" />
22 | <.input field={{f, :public}} type="checkbox" label="Public?" />
23 | <:actions>
24 | <.button phx-disable-with="Saving...">Save Tweet
25 |
26 |
27 |
28 | """
29 | end
30 |
31 | @impl true
32 | def update(%{tweet: tweet, current_user: current_user} = assigns, socket) do
33 | form =
34 | if tweet do
35 | AshPhoenix.Form.for_action(tweet, :update,
36 | as: "tweet",
37 | api: Twitter.Tweets,
38 | actor: current_user
39 | )
40 | else
41 | AshPhoenix.Form.for_action(Twitter.Tweets.Tweet, :create,
42 | as: "tweet",
43 | api: Twitter.Tweets,
44 | actor: current_user
45 | )
46 | end
47 |
48 | {:ok,
49 | socket
50 | |> assign(assigns)
51 | |> assign(:form, form)}
52 | end
53 |
54 | @impl true
55 | def handle_event("validate", %{"tweet" => tweet_params}, socket) do
56 | {:noreply, assign(socket, :form, AshPhoenix.Form.validate(socket.assigns.form, tweet_params))}
57 | end
58 |
59 | def handle_event("save", %{"tweet" => tweet_params}, socket) do
60 | case AshPhoenix.Form.submit(socket.assigns.form, params: tweet_params) do
61 | {:ok, _tweet} ->
62 | message =
63 | case socket.assigns.form.type do
64 | :create ->
65 | "Tweet created successfully"
66 |
67 | :update ->
68 | "Tweet updated successfully"
69 | end
70 |
71 | {:noreply,
72 | socket
73 | |> put_flash(:info, message)
74 | |> push_navigate(to: socket.assigns.navigate)}
75 |
76 | {:error, form} ->
77 | {:noreply, assign(socket, form: form)}
78 | end
79 | end
80 | end
81 |
--------------------------------------------------------------------------------
/lib/twitter_web/live/tweet_live/index.ex:
--------------------------------------------------------------------------------
1 | defmodule TwitterWeb.TweetLive.Index do
2 | use TwitterWeb, :live_view
3 |
4 | @impl true
5 | def render(assigns) do
6 | ~H"""
7 | <.header>
8 | Listing Tweets
9 | <:actions>
10 | <%= if @pending_tweets > 0 do %>
11 | <.button phx-click="load-pending-tweets">Load <%= @pending_tweets %> new tweets
12 | <% end %>
13 | <.link patch={~p"/tweets/new"}>
14 | <.button>New Tweet
15 |
16 |
17 |
18 |
19 | <.table id="tweets" rows={@tweets} row_click={&JS.navigate(~p"/tweets/#{&1}")}>
20 | <:col :let={tweet} label="Email"><%= tweet.author_email %>
21 | <:col :let={tweet} label="Text">
22 | <%= tweet.text %>
23 | <%= if tweet.liked_by_user do %>
24 |
25 |
26 |
27 | <% else %>
28 |
29 |
30 |
31 | <% end %>
32 | <%= tweet.like_count %>
33 |
34 | <:action :let={tweet}>
35 |
36 | <.link navigate={~p"/tweets/#{tweet}"}>Show
37 |
38 | <.link patch={~p"/tweets/#{tweet}/edit"}>Edit
39 |
40 | <:action :let={tweet}>
41 | <.link phx-click={JS.push("delete", value: %{id: tweet.id})} data-confirm="Are you sure?">
42 | Delete
43 |
44 |
45 |
46 |
47 | <.modal
48 | :if={@live_action in [:new, :edit]}
49 | id="tweet-modal"
50 | show
51 | on_cancel={JS.navigate(~p"/tweets")}
52 | >
53 | <.live_component
54 | module={TwitterWeb.TweetLive.FormComponent}
55 | current_user={@current_user}
56 | id={if @tweet, do: @tweet.id, else: :new}
57 | tweet={@tweet}
58 | title={@page_title}
59 | action={@live_action}
60 | navigate={~p"/tweets"}
61 | />
62 |
63 | """
64 | end
65 |
66 | @impl true
67 | def mount(_params, _session, socket) do
68 | TwitterWeb.Endpoint.subscribe("tweets:created")
69 |
70 | {:ok, assign(socket, pending_tweets: 0) |> assign_tweets()}
71 | end
72 |
73 | @impl true
74 | def handle_params(params, _url, socket) do
75 | {:noreply, apply_action(socket, socket.assigns.live_action, params)}
76 | end
77 |
78 | @impl true
79 | def handle_event("load-pending-tweets", _, socket) do
80 | socket.assigns.tweets
81 | |> Enum.map(&unsubscribe(&1.id))
82 |
83 | {:noreply, socket |> assign_tweets() |> assign(pending_tweets: 0)}
84 | end
85 |
86 | def handle_event("like", %{"id" => id}, socket) do
87 | tweet =
88 | socket.assigns.tweets
89 | |> Enum.find(&(&1.id == id))
90 | |> Twitter.Tweets.Tweet.like!(actor: socket.assigns.current_user)
91 | |> Map.put(:liked_by_user, true)
92 |
93 | {:noreply, assign(socket, :tweets, replace_tweet(socket.assigns.tweets, tweet))}
94 | end
95 |
96 | @impl true
97 | def handle_event("dislike", %{"id" => id}, socket) do
98 | tweet =
99 | socket.assigns.tweets
100 | |> Enum.find(&(&1.id == id))
101 | |> Twitter.Tweets.Tweet.dislike!(actor: socket.assigns.current_user)
102 | |> Map.put(:liked_by_user, false)
103 |
104 | {:noreply, assign(socket, :tweets, replace_tweet(socket.assigns.tweets, tweet))}
105 | end
106 |
107 | def handle_event("delete", %{"id" => id}, socket) do
108 | id
109 | |> Twitter.Tweets.Tweet.get!()
110 | |> Twitter.Tweets.Tweet.destroy!()
111 |
112 | {:noreply, assign(socket, :tweets, remove_tweet(socket.assigns.tweets, id))}
113 | end
114 |
115 | @impl true
116 | def handle_info(
117 | %Phoenix.Socket.Broadcast{
118 | topic: "tweets:created",
119 | payload: %Ash.Notifier.Notification{data: %{id: id}, from: from}
120 | },
121 | socket
122 | ) do
123 | # This is a tweet we just published
124 | if from == self() do
125 | tweet = Twitter.Tweets.Tweet.get!(id, load: tweet_load(socket.assigns.current_user))
126 | {:noreply, assign(socket, :tweets, [tweet | socket.assigns.tweets])}
127 | else
128 | id
129 | |> Twitter.Tweets.Tweet.get!(
130 | load:
131 | tweet_load(socket.assigns.current_user) ++
132 | [visible_to: %{user_id: socket.assigns.current_user.id}]
133 | )
134 | |> case do
135 | %{visible_to: true} ->
136 | {:noreply, assign(socket, :pending_tweets, socket.assigns.pending_tweets + 1)}
137 |
138 | _ ->
139 | {:noreply, socket}
140 | end
141 | end
142 | end
143 |
144 | def handle_info(%Phoenix.Socket.Broadcast{topic: "tweets:liked:" <> id}, socket) do
145 | {:noreply, assign(socket, :tweets, tweet_liked(socket.assigns.tweets, id))}
146 | end
147 |
148 | @impl true
149 | def handle_info(%Phoenix.Socket.Broadcast{topic: "tweets:unliked:" <> id}, socket) do
150 | {:noreply, assign(socket, :tweets, tweet_unliked(socket.assigns.tweets, id))}
151 | end
152 |
153 | defp apply_action(socket, :edit, %{"id" => id}) do
154 | socket
155 | |> assign(:page_title, "Edit Tweet")
156 | |> assign(:tweet, Twitter.Tweets.Tweet.get!(id))
157 | end
158 |
159 | defp apply_action(socket, :new, _params) do
160 | socket
161 | |> assign(:page_title, "New Tweet")
162 | |> assign(:tweet, nil)
163 | end
164 |
165 | defp apply_action(socket, :index, _params) do
166 | socket
167 | |> assign(:page_title, "Listing Tweets")
168 | |> assign(:tweet, nil)
169 | end
170 |
171 | defp assign_tweets(socket) do
172 | tweets =
173 | Twitter.Tweets.Tweet.feed!(socket.assigns.current_user.id,
174 | load: tweet_load(socket.assigns.current_user)
175 | )
176 |
177 | Enum.map(tweets, &subscribe/1)
178 | assign(socket, tweets: tweets)
179 | end
180 |
181 | defp remove_tweet(tweets, id) do
182 | unsubscribe(id)
183 | Enum.reject(tweets, &(&1.id == id))
184 | end
185 |
186 | defp replace_tweet(tweets, tweet) do
187 | Enum.map(tweets, fn current_tweet ->
188 | if current_tweet.id == tweet.id do
189 | tweet
190 | else
191 | current_tweet
192 | end
193 | end)
194 | end
195 |
196 | defp subscribe(tweet) do
197 | TwitterWeb.Endpoint.subscribe("tweets:liked:#{tweet.id}")
198 | TwitterWeb.Endpoint.subscribe("tweets:unliked:#{tweet.id}")
199 | end
200 |
201 | defp unsubscribe(id) do
202 | TwitterWeb.Endpoint.unsubscribe("tweets:liked:#{id}")
203 | TwitterWeb.Endpoint.unsubscribe("tweets:unliked:#{id}")
204 | end
205 |
206 | defp tweet_liked(tweets, id) do
207 | update_tweet(tweets, id, &%{&1 | like_count: &1.like_count + 1})
208 | end
209 |
210 | defp tweet_unliked(tweets, id) do
211 | update_tweet(tweets, id, &%{&1 | like_count: &1.like_count - 1})
212 | end
213 |
214 | defp update_tweet(tweets, id, func) do
215 | Enum.map(tweets, fn tweet ->
216 | if tweet.id == id do
217 | func.(tweet)
218 | else
219 | tweet
220 | end
221 | end)
222 | end
223 |
224 | defp tweet_load(current_user) do
225 | [
226 | :author_email,
227 | :like_count,
228 | liked_by_user: %{user_id: current_user.id}
229 | ]
230 | end
231 | end
232 |
--------------------------------------------------------------------------------
/lib/twitter_web/live/tweet_live/show.ex:
--------------------------------------------------------------------------------
1 | defmodule TwitterWeb.TweetLive.Show do
2 | use TwitterWeb, :live_view
3 |
4 | @impl true
5 | def mount(_params, _session, socket) do
6 | {:ok, socket}
7 | end
8 |
9 | @impl true
10 | def handle_params(%{"id" => id}, _, socket) do
11 | {:noreply,
12 | socket
13 | |> assign(:page_title, page_title(socket.assigns.live_action))
14 | |> assign(:tweet, Twitter.Tweets.Tweet.get!(id))}
15 | end
16 |
17 | defp page_title(:show), do: "Show Tweet"
18 | defp page_title(:edit), do: "Edit Tweet"
19 | end
20 |
--------------------------------------------------------------------------------
/lib/twitter_web/live/tweet_live/show.html.heex:
--------------------------------------------------------------------------------
1 | <.header>
2 | Tweet <%= @tweet.id %>
3 | <:subtitle>This is a tweet record from your database.
4 | <:actions>
5 | <.link patch={~p"/tweets/#{@tweet}/show/edit"} phx-click={JS.push_focus()}>
6 | <.button>Edit tweet
7 |
8 |
9 |
10 |
11 | <.list>
12 | <:item title="Text"><%= @tweet.text %>
13 |
14 |
15 | <.back navigate={~p"/tweets"}>Back to tweets
16 |
17 | <.modal
18 | :if={@live_action == :edit}
19 | id="tweet-modal"
20 | show
21 | on_cancel={JS.patch(~p"/tweets/#{@tweet}")}
22 | >
23 | <.live_component
24 | module={TwitterWeb.TweetLive.FormComponent}
25 | current_user={@current_user}
26 | id={@tweet.id}
27 | title={@page_title}
28 | action={@live_action}
29 | tweet={@tweet}
30 | navigate={~p"/tweets/#{@tweet}"}
31 | />
32 |
33 |
--------------------------------------------------------------------------------
/lib/twitter_web/live_user_auth.ex:
--------------------------------------------------------------------------------
1 | defmodule AshHqWeb.LiveUserAuth do
2 | @moduledoc """
3 | Helpers for authenticating users in liveviews
4 | """
5 |
6 | import Phoenix.Component
7 |
8 | def on_mount(:live_user_optional, _params, _session, socket) do
9 | if socket.assigns[:current_user] do
10 | {:cont, socket}
11 | else
12 | {:cont, assign(socket, :current_user, nil)}
13 | end
14 | end
15 |
16 | def on_mount(:live_user_required, _params, _session, socket) do
17 | if socket.assigns[:current_user] do
18 | {:cont, socket}
19 | else
20 | {:halt, Phoenix.LiveView.redirect(socket, to: "/sign-in")}
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/lib/twitter_web/router.ex:
--------------------------------------------------------------------------------
1 | defmodule TwitterWeb.Router do
2 | use TwitterWeb, :router
3 | use AshAuthentication.Phoenix.Router
4 | import AshAuthentication.Phoenix.LiveSession
5 | import AshAdmin.Router
6 |
7 | pipeline :browser do
8 | plug :accepts, ["html"]
9 | plug :fetch_session
10 | plug :fetch_live_flash
11 | plug :put_root_layout, {TwitterWeb.Layouts, :root}
12 | plug :protect_from_forgery
13 | plug :put_secure_browser_headers
14 | plug :load_from_session
15 | end
16 |
17 | pipeline :api do
18 | plug :accepts, ["json"]
19 | end
20 |
21 | scope "/" do
22 | # Pipe it through your browser pipeline
23 | pipe_through [:browser]
24 |
25 | ash_admin("/admin")
26 | end
27 |
28 | scope "/", TwitterWeb do
29 | pipe_through :browser
30 |
31 | reset_route []
32 |
33 | sign_in_route overrides: [
34 | TwitterWeb.AuthOverrides,
35 | AshAuthentication.Phoenix.Overrides.Default
36 | ]
37 |
38 | sign_out_route AuthController
39 | auth_routes_for Twitter.Accounts.User, to: AuthController
40 |
41 | scope "/", TweetLive do
42 | ash_authentication_live_session :authenticated_tweets_root,
43 | on_mount: {AshHqWeb.LiveUserAuth, :live_user_required} do
44 | live "/", Index, :index
45 | end
46 | end
47 |
48 | scope "/tweets", TweetLive do
49 | ash_authentication_live_session :authenticated_tweets,
50 | on_mount: {AshHqWeb.LiveUserAuth, :live_user_required} do
51 | live "/", Index, :index
52 | live "/new", Index, :new
53 | live "/:id/edit", Index, :edit
54 |
55 | live "/:id", Show, :show
56 | live "/:id/show/edit", Show, :edit
57 | end
58 | end
59 | end
60 |
61 | # Other scopes may use custom stacks.
62 | # scope "/api", TwitterWeb do
63 | # pipe_through :api
64 | # end
65 |
66 | # Enable LiveDashboard and Swoosh mailbox preview in development
67 | if Application.compile_env(:twitter, :dev_routes) do
68 | # If you want to use the LiveDashboard in production, you should put
69 | # it behind authentication and allow only admins to access it.
70 | # If your application does not have an admins-only section yet,
71 | # you can use Plug.BasicAuth to set up some basic authentication
72 | # as long as you are also using SSL (which you should anyway).
73 | import Phoenix.LiveDashboard.Router
74 |
75 | scope "/dev" do
76 | pipe_through :browser
77 |
78 | live_dashboard "/dashboard", metrics: TwitterWeb.Telemetry
79 | forward "/mailbox", Plug.Swoosh.MailboxPreview
80 | end
81 | end
82 | end
83 |
--------------------------------------------------------------------------------
/lib/twitter_web/telemetry.ex:
--------------------------------------------------------------------------------
1 | defmodule TwitterWeb.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.start.system_time",
26 | unit: {:native, :millisecond}
27 | ),
28 | summary("phoenix.endpoint.stop.duration",
29 | unit: {:native, :millisecond}
30 | ),
31 | summary("phoenix.router_dispatch.start.system_time",
32 | tags: [:route],
33 | unit: {:native, :millisecond}
34 | ),
35 | summary("phoenix.router_dispatch.exception.duration",
36 | tags: [:route],
37 | unit: {:native, :millisecond}
38 | ),
39 | summary("phoenix.router_dispatch.stop.duration",
40 | tags: [:route],
41 | unit: {:native, :millisecond}
42 | ),
43 | summary("phoenix.socket_connected.duration",
44 | unit: {:native, :millisecond}
45 | ),
46 | summary("phoenix.channel_join.duration",
47 | unit: {:native, :millisecond}
48 | ),
49 | summary("phoenix.channel_handled_in.duration",
50 | tags: [:event],
51 | unit: {:native, :millisecond}
52 | ),
53 |
54 | # Database Metrics
55 | summary("twitter.repo.query.total_time",
56 | unit: {:native, :millisecond},
57 | description: "The sum of the other measurements"
58 | ),
59 | summary("twitter.repo.query.decode_time",
60 | unit: {:native, :millisecond},
61 | description: "The time spent decoding the data received from the database"
62 | ),
63 | summary("twitter.repo.query.query_time",
64 | unit: {:native, :millisecond},
65 | description: "The time spent executing the query"
66 | ),
67 | summary("twitter.repo.query.queue_time",
68 | unit: {:native, :millisecond},
69 | description: "The time spent waiting for a database connection"
70 | ),
71 | summary("twitter.repo.query.idle_time",
72 | unit: {:native, :millisecond},
73 | description:
74 | "The time the connection spent waiting before being checked out for the query"
75 | ),
76 |
77 | # VM Metrics
78 | summary("vm.memory.total", unit: {:byte, :kilobyte}),
79 | summary("vm.total_run_queue_lengths.total"),
80 | summary("vm.total_run_queue_lengths.cpu"),
81 | summary("vm.total_run_queue_lengths.io")
82 | ]
83 | end
84 |
85 | defp periodic_measurements do
86 | [
87 | # A module, function and arguments to be invoked periodically.
88 | # This function must call :telemetry.execute/3 and a metric must be added above.
89 | # {TwitterWeb, :count_users, []}
90 | ]
91 | end
92 | end
93 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Twitter.MixProject do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :twitter,
7 | version: "0.1.0",
8 | elixir: "~> 1.14",
9 | elixirc_paths: elixirc_paths(Mix.env()),
10 | start_permanent: Mix.env() == :prod,
11 | aliases: aliases(),
12 | deps: deps()
13 | ]
14 | end
15 |
16 | # Configuration for the OTP application.
17 | #
18 | # Type `mix help compile.app` for more information.
19 | def application do
20 | [
21 | mod: {Twitter.Application, []},
22 | extra_applications: [:logger, :runtime_tools]
23 | ]
24 | end
25 |
26 | # Specifies which paths to compile per environment.
27 | defp elixirc_paths(:test), do: ["lib", "test/support"]
28 | defp elixirc_paths(_), do: ["lib"]
29 |
30 | # Specifies your project dependencies.
31 | #
32 | # Type `mix help deps` for examples and options.
33 | defp deps do
34 | [
35 | # Ash
36 | {:ash, "~> 2.5"},
37 | {:ash_postgres, "~> 1.3"},
38 | {:ash_authentication, "~> 3.7"},
39 | {:ash_phoenix, "~> 1.2"},
40 | {:phoenix_view, "~> 2.0"},
41 | {:ash_authentication_phoenix, "~> 1.4"},
42 | {:ash_admin, "~> 0.7.1"},
43 | # Phoenix
44 | {:phoenix, "~> 1.7.0-rc.0", override: true},
45 | {:phoenix_ecto, "~> 4.4"},
46 | {:ecto_sql, "~> 3.10.2"},
47 | {:postgrex, ">= 0.0.0"},
48 | {:phoenix_html, "~> 3.0"},
49 | {:phoenix_live_reload, "~> 1.2", only: :dev},
50 | {:phoenix_live_view, "~> 0.18.3"},
51 | {:heroicons, "~> 0.5"},
52 | {:floki, ">= 0.30.0", only: :test},
53 | {:phoenix_live_dashboard, "~> 0.7.2"},
54 | {:esbuild, "~> 0.5", runtime: Mix.env() == :dev},
55 | {:tailwind, "~> 0.1.8", runtime: Mix.env() == :dev},
56 | {:swoosh, "~> 1.3"},
57 | {:finch, "~> 0.13"},
58 | {:telemetry_metrics, "~> 0.6"},
59 | {:telemetry_poller, "~> 1.0"},
60 | {:gettext, "~> 0.20"},
61 | {:jason, "~> 1.2"},
62 | {:plug_cowboy, "~> 2.5"}
63 | ]
64 | end
65 |
66 | # Aliases are shortcuts or tasks specific to the current project.
67 | # For example, to install project dependencies and perform other setup tasks, run:
68 | #
69 | # $ mix setup
70 | #
71 | # See the documentation for `Mix` for more info on aliases.
72 | defp aliases do
73 | [
74 | setup: ["deps.get", "ecto.setup"],
75 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
76 | "ecto.reset": ["ecto.drop", "ecto.setup"],
77 | test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
78 | "assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"]
79 | ]
80 | end
81 | end
82 |
--------------------------------------------------------------------------------
/mix.lock:
--------------------------------------------------------------------------------
1 | %{
2 | "ash": {:hex, :ash, "2.5.8", "5b3fe21ebffc9eb06814c6a589cfb8bf1e411010dd177d6edd4038a1cd05158a", [: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.0", [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", "fab5b0819350ea3269afe34a12c610cd33e77b677c9b2b47a040bb64a29b601a"},
3 | "ash_admin": {:hex, :ash_admin, "0.7.1", "ccd5344256c62fea0dab2e9eb6473dacb5f0de2c1dc6e76c9fd2e986ea9c2725", [:mix], [{:ash, "~> 2.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 1.1", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, 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]}, {:surface, "~> 0.9.1", [hex: :surface, repo: "hexpm", optional: false]}], "hexpm", "bc9b182052918f6eb922c4d0328b5390278131ebb4d721b0503dec561cd82182"},
4 | "ash_authentication": {:hex, :ash_authentication, "3.7.3", "12fcc7175867ed7751ea125b1d2affc64becfbe0acd3324dcec290a21bfd12eb", [:mix], [{:ash, "~> 2.4", [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.3.4", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "3f3f75ce22e820f062d05bb3cfc2040c189fb7745ff3568c6387ba114d8c20b2"},
5 | "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "1.4.4", "fdcd24861a39bb9ba8c35593eec50bed36f1edee78692a2390896887747598c6", [: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", "cc2099a8ffa38e8aefb8f6c616af3016264342918e07fcf65d817cd4283146c8"},
6 | "ash_phoenix": {:hex, :ash_phoenix, "1.2.4", "3582bf24b3341b04e7fdcfe385fd32093462d037f30e7a8bce74c23ad4c94b69", [:mix], [{:ash, ">= 2.4.0", [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", "4768e8a11273023d8aa2cfa0b591e7a1fbc3ccc50be866959398a163a135b50f"},
7 | "ash_postgres": {:hex, :ash_postgres, "1.3.3", "34d94cc78538b6b935c19aedccda2e737a21b06b395a3e0e4b9635eeb1d4199e", [:mix], [{:ash, "~> 2.5", [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", "bc3cad79e62c06ea93b237bc07cea8603336c1e64edc5e5a8025a6b5449f477c"},
8 | "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"},
9 | "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"},
10 | "castore": {:hex, :castore, "0.1.22", "4127549e411bedd012ca3a308dede574f43819fe9394254ca55ab4895abfa1a2", [:mix], [], "hexpm", "c17576df47eb5aa1ee40cc4134316a99f5cad3e215d5c77b8dd3cfef12a22cac"},
11 | "comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"},
12 | "comparable": {:hex, :comparable, "1.0.0", "bb669e91cedd14ae9937053e5bcbc3c52bb2f22422611f43b6e38367d94a495f", [:mix], [{:typable, "~> 0.1", [hex: :typable, repo: "hexpm", optional: false]}], "hexpm", "277c11eeb1cd726e7cd41c6c199e7e52fa16ee6830b45ad4cdc62e51f62eb60c"},
13 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"},
14 | "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"},
15 | "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"},
16 | "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},
17 | "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"},
18 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
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.4.1 or ~> 2.5", [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.3", "c37fdae1b52d2cc51069713a58c2314877c1ad40800a57efb213f77b078a460d", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "24ada3e3996adbed1fa024ca14995ef2ba3d0d17b678b0f3f2b1f66e6ce2b274"},
22 | "esbuild": {:hex, :esbuild, "0.6.0", "9ba6ead054abd43cb3d7b14946a0cdd1493698ccd8e054e0e5d6286d7f0f509c", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "30f9a05d4a5bab0d3e37398f312f80864e1ee1a081ca09149d06d474318fd040"},
23 | "ets": {:hex, :ets, "0.8.1", "8ff9bcda5682b98493f8878fc9dbd990e48d566cba8cce59f7c2a78130da29ea", [:mix], [], "hexpm", "6be41b50adb5bc5c43626f25ea2d0af1f4a242fb3fad8d53f0c67c20b78915cc"},
24 | "expo": {:hex, :expo, "0.1.0", "d4e932bdad052c374118e312e35280f1919ac13881cb3ac07a209a54d0c81dd8", [:mix], [], "hexpm", "c22c536021c56de058aaeedeabb4744eb5d48137bacf8c29f04d25b6c6bbbf45"},
25 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
26 | "finch": {:hex, :finch, "0.14.0", "619bfdee18fc135190bf590356c4bf5d5f71f916adb12aec94caa3fa9267a4bc", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5459acaf18c4fdb47a8c22fb3baff5d8173106217c8e56c5ba0b93e66501a8dd"},
27 | "floki": {:hex, :floki, "0.34.0", "002d0cc194b48794d74711731db004fafeb328fe676976f160685262d43706a8", [:mix], [], "hexpm", "9c3a9f43f40dde00332a589bd9d389b90c1f518aef500364d00636acc5ebc99c"},
28 | "gettext": {:hex, :gettext, "0.21.0", "15bbceb20b317b706a8041061a08e858b5a189654128618b53746bf36c84352b", [:mix], [{:expo, "~> 0.1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "04a66db4103b6d1d18f92240bb2c73167b517229316b7bef84e4eebbfb2f14f6"},
29 | "heroicons": {:hex, :heroicons, "0.5.2", "a7ae72460ecc4b74a4ba9e72f0b5ac3c6897ad08968258597da11c2b0b210683", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "7ef96f455c1c136c335f1da0f1d7b12c34002c80a224ad96fc0ebf841a6ffef5"},
30 | "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"},
31 | "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"},
32 | "joken": {:hex, :joken, "2.5.0", "09be497d804b8115eb6f07615cef2e60c2a1008fb89dc0aef0d4c4b4609b99aa", [:mix], [{:jose, "~> 1.11.2", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "22b25c89617c5ed8ca7b31026340a25ea0f9ca7160f9706b79be9ed81fdf74e7"},
33 | "jose": {:hex, :jose, "1.11.5", "3bc2d75ffa5e2c941ca93e5696b54978323191988eb8d225c2e663ddfefd515e", [:mix, :rebar3], [], "hexpm", "dcd3b215bafe02ea7c5b23dafd3eb8062a5cd8f2d904fd9caa323d37034ab384"},
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 | "nimble_options": {:hex, :nimble_options, "0.5.2", "42703307b924880f8c08d97719da7472673391905f528259915782bb346e0a1b", [:mix], [], "hexpm", "4da7f904b915fd71db549bcdc25f8d56f378ef7ae07dc1d372cbe72ba950dce0"},
37 | "nimble_pool": {:hex, :nimble_pool, "0.2.6", "91f2f4c357da4c4a0a548286c84a3a28004f68f05609b4534526871a22053cde", [:mix], [], "hexpm", "1c715055095d3f2705c4e236c18b618420a35490da94149ff8b580a2144f653f"},
38 | "phoenix": {:hex, :phoenix, "1.7.0-rc.2", "8faaff6f699aad2fe6a003c627da65d0864c868a4c10973ff90abfd7286c1f27", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [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]}, {:websock_adapter, "~> 0.4", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "71abde2f67330c55b625dcc0e42bf76662dbadc7553c4f545c2f3759f40f7487"},
39 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"},
40 | "phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"},
41 | "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"},
42 | "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"},
43 | "phoenix_live_view": {:hex, :phoenix_live_view, "0.18.11", "c50eac83dae6b5488859180422dfb27b2c609de87f4aa5b9c926ecd0501cd44f", [: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.1", [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", "76c99a0ffb47cd95bf06a917e74f282a603f3e77b00375f3c2dd95110971b102"},
44 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"},
45 | "phoenix_template": {:hex, :phoenix_template, "1.0.0", "c57bc5044f25f007dc86ab21895688c098a9f846a8dda6bc40e2d0ddc146e38f", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "1b066f99a26fd22064c12b2600a9a6e56700f591bf7b20b418054ea38b4d4357"},
46 | "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"},
47 | "picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},
48 | "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"},
49 | "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"},
50 | "plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"},
51 | "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"},
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.11.2", "549ce48be666421ac60cfb7f59c8752e0d393baa0b14d06271d3f6a8c1b027ab", [:mix], [], "hexpm", "9ab659118896a36be6eec68ff7b0674cba372fc8e210b1e9dc8cf2b55bb70dfb"},
55 | "spark": {:hex, :spark, "0.3.7", "3db6afd3e331c1f246c632b1cc66fc4031438080de39cf78e629acebeb3b85f4", [:mix], [{:nimble_options, "~> 0.5", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "dab601e75995795e9c1546ff2a9d5d901b03407d36f78776b402f397c7230a0c"},
56 | "stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"},
57 | "surface": {:hex, :surface, "0.9.1", "6a343564b1d6c17c619ac933cec5680ffe8c68f0cd2d85f780b70f6607750a96", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.11", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "131312d35d190739d0e0f1681acb9fce6962d3f81439011a3331bad2976ca372"},
58 | "swoosh": {:hex, :swoosh, "1.9.1", "0a5d7bf9954eb41d7e55525bc0940379982b090abbaef67cd8e1fd2ed7f8ca1a", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "76dffff3ffcab80f249d5937a592eaef7cc49ac6f4cdd27e622868326ed6371e"},
59 | "tailwind": {:hex, :tailwind, "0.1.9", "25ba09d42f7bfabe170eb67683a76d6ec2061952dc9bd263a52a99ba3d24bd4d", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "9213f87709c458aaec313bb5f2df2b4d2cedc2b630e4ae821bf3c54c47a56d0b"},
60 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
61 | "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"},
62 | "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"},
63 | "typable": {:hex, :typable, "0.3.0", "0431e121d124cd26f312123e313d2689b9a5322b15add65d424c07779eaa3ca1", [:mix], [], "hexpm", "880a0797752da1a4c508ac48f94711e04c86156f498065a83d160eef945858f8"},
64 | "websock": {:hex, :websock, "0.4.3", "184ac396bdcd3dfceb5b74c17d221af659dd559a95b1b92041ecb51c9b728093", [:mix], [], "hexpm", "5e4dd85f305f43fd3d3e25d70bec4a45228dfed60f0f3b072d8eddff335539cf"},
65 | "websock_adapter": {:hex, :websock_adapter, "0.4.5", "30038a3715067f51a9580562c05a3a8d501126030336ffc6edb53bf57d6d2d26", [:mix], [{:bandit, "~> 0.6", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.4", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "1d9812dc7e703c205049426fd4fe0852a247a825f91b099e53dc96f68bafe4c8"},
66 | }
67 |
--------------------------------------------------------------------------------
/priv/gettext/en/LC_MESSAGES/errors.po:
--------------------------------------------------------------------------------
1 | ## `msgid`s in this file come from POT (.pot) files.
2 | ##
3 | ## Do not add, change, or remove `msgid`s manually here as
4 | ## they're tied to the ones in the corresponding POT file
5 | ## (with the same domain).
6 | ##
7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge`
8 | ## to merge POT files into PO files.
9 | msgid ""
10 | msgstr ""
11 | "Language: en\n"
12 |
13 | ## From Ecto.Changeset.cast/4
14 | msgid "can't be blank"
15 | msgstr ""
16 |
17 | ## From Ecto.Changeset.unique_constraint/3
18 | msgid "has already been taken"
19 | msgstr ""
20 |
21 | ## From Ecto.Changeset.put_change/3
22 | msgid "is invalid"
23 | msgstr ""
24 |
25 | ## From Ecto.Changeset.validate_acceptance/3
26 | msgid "must be accepted"
27 | msgstr ""
28 |
29 | ## From Ecto.Changeset.validate_format/3
30 | msgid "has invalid format"
31 | msgstr ""
32 |
33 | ## From Ecto.Changeset.validate_subset/3
34 | msgid "has an invalid entry"
35 | msgstr ""
36 |
37 | ## From Ecto.Changeset.validate_exclusion/3
38 | msgid "is reserved"
39 | msgstr ""
40 |
41 | ## From Ecto.Changeset.validate_confirmation/3
42 | msgid "does not match confirmation"
43 | msgstr ""
44 |
45 | ## From Ecto.Changeset.no_assoc_constraint/3
46 | msgid "is still associated with this entry"
47 | msgstr ""
48 |
49 | msgid "are still associated with this entry"
50 | msgstr ""
51 |
52 | ## From Ecto.Changeset.validate_length/3
53 | msgid "should have %{count} item(s)"
54 | msgid_plural "should have %{count} item(s)"
55 | msgstr[0] ""
56 | msgstr[1] ""
57 |
58 | msgid "should be %{count} character(s)"
59 | msgid_plural "should be %{count} character(s)"
60 | msgstr[0] ""
61 | msgstr[1] ""
62 |
63 | msgid "should be %{count} byte(s)"
64 | msgid_plural "should be %{count} byte(s)"
65 | msgstr[0] ""
66 | msgstr[1] ""
67 |
68 | msgid "should have at least %{count} item(s)"
69 | msgid_plural "should have at least %{count} item(s)"
70 | msgstr[0] ""
71 | msgstr[1] ""
72 |
73 | msgid "should be at least %{count} character(s)"
74 | msgid_plural "should be at least %{count} character(s)"
75 | msgstr[0] ""
76 | msgstr[1] ""
77 |
78 | msgid "should be at least %{count} byte(s)"
79 | msgid_plural "should be at least %{count} byte(s)"
80 | msgstr[0] ""
81 | msgstr[1] ""
82 |
83 | msgid "should have at most %{count} item(s)"
84 | msgid_plural "should have at most %{count} item(s)"
85 | msgstr[0] ""
86 | msgstr[1] ""
87 |
88 | msgid "should be at most %{count} character(s)"
89 | msgid_plural "should be at most %{count} character(s)"
90 | msgstr[0] ""
91 | msgstr[1] ""
92 |
93 | msgid "should be at most %{count} byte(s)"
94 | msgid_plural "should be at most %{count} byte(s)"
95 | msgstr[0] ""
96 | msgstr[1] ""
97 |
98 | ## From Ecto.Changeset.validate_number/3
99 | msgid "must be less than %{number}"
100 | msgstr ""
101 |
102 | msgid "must be greater than %{number}"
103 | msgstr ""
104 |
105 | msgid "must be less than or equal to %{number}"
106 | msgstr ""
107 |
108 | msgid "must be greater than or equal to %{number}"
109 | msgstr ""
110 |
111 | msgid "must be equal to %{number}"
112 | msgstr ""
113 |
--------------------------------------------------------------------------------
/priv/gettext/errors.pot:
--------------------------------------------------------------------------------
1 | ## This is a PO Template file.
2 | ##
3 | ## `msgid`s here are often extracted from source code.
4 | ## Add new translations manually only if they're dynamic
5 | ## translations that can't be statically extracted.
6 | ##
7 | ## Run `mix gettext.extract` to bring this file up to
8 | ## date. Leave `msgstr`s empty as changing them here has no
9 | ## effect: edit them in PO (`.po`) files instead.
10 |
11 | ## From Ecto.Changeset.cast/4
12 | msgid "can't be blank"
13 | msgstr ""
14 |
15 | ## From Ecto.Changeset.unique_constraint/3
16 | msgid "has already been taken"
17 | msgstr ""
18 |
19 | ## From Ecto.Changeset.put_change/3
20 | msgid "is invalid"
21 | msgstr ""
22 |
23 | ## From Ecto.Changeset.validate_acceptance/3
24 | msgid "must be accepted"
25 | msgstr ""
26 |
27 | ## From Ecto.Changeset.validate_format/3
28 | msgid "has invalid format"
29 | msgstr ""
30 |
31 | ## From Ecto.Changeset.validate_subset/3
32 | msgid "has an invalid entry"
33 | msgstr ""
34 |
35 | ## From Ecto.Changeset.validate_exclusion/3
36 | msgid "is reserved"
37 | msgstr ""
38 |
39 | ## From Ecto.Changeset.validate_confirmation/3
40 | msgid "does not match confirmation"
41 | msgstr ""
42 |
43 | ## From Ecto.Changeset.no_assoc_constraint/3
44 | msgid "is still associated with this entry"
45 | msgstr ""
46 |
47 | msgid "are still associated with this entry"
48 | msgstr ""
49 |
50 | ## From Ecto.Changeset.validate_length/3
51 | msgid "should have %{count} item(s)"
52 | msgid_plural "should have %{count} item(s)"
53 | msgstr[0] ""
54 | msgstr[1] ""
55 |
56 | msgid "should be %{count} character(s)"
57 | msgid_plural "should be %{count} character(s)"
58 | msgstr[0] ""
59 | msgstr[1] ""
60 |
61 | msgid "should be %{count} byte(s)"
62 | msgid_plural "should be %{count} byte(s)"
63 | msgstr[0] ""
64 | msgstr[1] ""
65 |
66 | msgid "should have at least %{count} item(s)"
67 | msgid_plural "should have at least %{count} item(s)"
68 | msgstr[0] ""
69 | msgstr[1] ""
70 |
71 | msgid "should be at least %{count} character(s)"
72 | msgid_plural "should be at least %{count} character(s)"
73 | msgstr[0] ""
74 | msgstr[1] ""
75 |
76 | msgid "should be at least %{count} byte(s)"
77 | msgid_plural "should be at least %{count} byte(s)"
78 | msgstr[0] ""
79 | msgstr[1] ""
80 |
81 | msgid "should have at most %{count} item(s)"
82 | msgid_plural "should have at most %{count} item(s)"
83 | msgstr[0] ""
84 | msgstr[1] ""
85 |
86 | msgid "should be at most %{count} character(s)"
87 | msgid_plural "should be at most %{count} character(s)"
88 | msgstr[0] ""
89 | msgstr[1] ""
90 |
91 | msgid "should be at most %{count} byte(s)"
92 | msgid_plural "should be at most %{count} byte(s)"
93 | msgstr[0] ""
94 | msgstr[1] ""
95 |
96 | ## From Ecto.Changeset.validate_number/3
97 | msgid "must be less than %{number}"
98 | msgstr ""
99 |
100 | msgid "must be greater than %{number}"
101 | msgstr ""
102 |
103 | msgid "must be less than or equal to %{number}"
104 | msgstr ""
105 |
106 | msgid "must be greater than or equal to %{number}"
107 | msgstr ""
108 |
109 | msgid "must be equal to %{number}"
110 | msgstr ""
111 |
--------------------------------------------------------------------------------
/priv/repo/migrations/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:ecto_sql],
3 | inputs: ["*.exs"]
4 | ]
5 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20230120180920_install_citext_extension.exs:
--------------------------------------------------------------------------------
1 | defmodule Twitter.Repo.Migrations.InstallCitext 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 \"citext\"")
12 | end
13 |
14 | def down do
15 | # Uncomment this if you actually want to uninstall the extensions
16 | # when this migration is rolled back:
17 | # execute("DROP EXTENSION IF EXISTS \"citext\"")
18 | end
19 | end
--------------------------------------------------------------------------------
/priv/repo/migrations/20230120180921_migrate_resources1.exs:
--------------------------------------------------------------------------------
1 | defmodule Twitter.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 | create table(:users, primary_key: false) do
12 | add :id, :uuid, null: false, primary_key: true
13 | add :email, :citext, null: false
14 | add :hashed_password, :text
15 | add :inserted_at, :utc_datetime_usec, null: false, default: fragment("now()")
16 | add :updated_at, :utc_datetime_usec, null: false, default: fragment("now()")
17 | end
18 |
19 | create unique_index(:users, [:email], name: "users_unique_email_index")
20 |
21 | create table(:tweets, primary_key: false) do
22 | add :id, :uuid, null: false, primary_key: true
23 | add :text, :text, null: false
24 | add :visibility, :text
25 | add :inserted_at, :utc_datetime_usec, null: false, default: fragment("now()")
26 | add :updated_at, :utc_datetime_usec, null: false, default: fragment("now()")
27 |
28 | add :author_id,
29 | references(:users,
30 | column: :id,
31 | name: "tweets_author_id_fkey",
32 | type: :uuid,
33 | prefix: "public"
34 | ),
35 | null: false
36 | end
37 |
38 | create table(:tokens, primary_key: false) do
39 | add :created_at, :utc_datetime_usec, null: false, default: fragment("now()")
40 | add :extra_data, :map
41 | add :purpose, :text, null: false
42 | add :expires_at, :utc_datetime, null: false
43 | add :subject, :text, null: false
44 | add :jti, :text, null: false, primary_key: true
45 | add :id, :uuid, null: false, primary_key: true
46 | add :inserted_at, :utc_datetime_usec, null: false, default: fragment("now()")
47 | add :updated_at, :utc_datetime_usec, null: false, default: fragment("now()")
48 | end
49 |
50 | create table(:likes, primary_key: false) do
51 | add :id, :uuid, null: false, primary_key: true
52 | add :inserted_at, :utc_datetime_usec, null: false, default: fragment("now()")
53 | add :updated_at, :utc_datetime_usec, null: false, default: fragment("now()")
54 |
55 | add :user_id,
56 | references(:users,
57 | column: :id,
58 | name: "likes_user_id_fkey",
59 | type: :uuid,
60 | prefix: "public",
61 | on_delete: :delete_all
62 | ),
63 | null: false
64 |
65 | add :tweet_id,
66 | references(:tweets,
67 | column: :id,
68 | name: "likes_tweet_id_fkey",
69 | type: :uuid,
70 | prefix: "public",
71 | on_delete: :delete_all
72 | ),
73 | null: false
74 | end
75 |
76 | create unique_index(:likes, [:user_id, :tweet_id], name: "likes_unique_user_and_tweet_index")
77 |
78 | create table(:friend_links, primary_key: false) do
79 | add :id, :uuid, null: false, primary_key: true
80 | add :status, :text, null: false, default: "pending"
81 |
82 | add :source_user_id,
83 | references(:users,
84 | column: :id,
85 | name: "friend_links_source_user_id_fkey",
86 | type: :uuid,
87 | prefix: "public"
88 | ),
89 | null: false
90 |
91 | add :destination_user_id,
92 | references(:users,
93 | column: :id,
94 | name: "friend_links_destination_user_id_fkey",
95 | type: :uuid,
96 | prefix: "public"
97 | ),
98 | null: false
99 | end
100 |
101 | create unique_index(:friend_links, [:source_user_id, :destination_user_id],
102 | name: "friend_links_unique_link_index"
103 | )
104 | end
105 |
106 | def down do
107 | drop_if_exists unique_index(:friend_links, [:source_user_id, :destination_user_id],
108 | name: "friend_links_unique_link_index"
109 | )
110 |
111 | drop constraint(:friend_links, "friend_links_destination_user_id_fkey")
112 |
113 | drop constraint(:friend_links, "friend_links_source_user_id_fkey")
114 |
115 | drop table(:friend_links)
116 |
117 | drop_if_exists unique_index(:likes, [:user_id, :tweet_id],
118 | name: "likes_unique_user_and_tweet_index"
119 | )
120 |
121 | drop constraint(:likes, "likes_tweet_id_fkey")
122 |
123 | drop constraint(:likes, "likes_user_id_fkey")
124 |
125 | drop table(:likes)
126 |
127 | drop table(:tokens)
128 |
129 | drop constraint(:tweets, "tweets_author_id_fkey")
130 |
131 | drop table(:tweets)
132 |
133 | drop_if_exists unique_index(:users, [:email], name: "users_unique_email_index")
134 |
135 | drop table(:users)
136 | end
137 | end
--------------------------------------------------------------------------------
/priv/repo/seeds.exs:
--------------------------------------------------------------------------------
1 | # Script for populating the database. You can run it as:
2 | #
3 | # mix run priv/repo/seeds.exs
4 | #
5 | # Inside the script, you can read and write to any of your
6 | # repositories directly:
7 | #
8 | # Twitter.Repo.insert!(%Twitter.SomeSchema{})
9 | #
10 | # We recommend using the bang functions (`insert!`, `update!`
11 | # and so on) as they will fail if something goes wrong.
12 |
--------------------------------------------------------------------------------
/priv/resource_snapshots/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "installed": [
3 | "citext"
4 | ]
5 | }
--------------------------------------------------------------------------------
/priv/resource_snapshots/repo/friend_links/20230120180921.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?": false,
15 | "default": "\"pending\"",
16 | "generated?": false,
17 | "primary_key?": false,
18 | "references": null,
19 | "size": null,
20 | "source": "status",
21 | "type": "text"
22 | },
23 | {
24 | "allow_nil?": false,
25 | "default": "nil",
26 | "generated?": false,
27 | "primary_key?": false,
28 | "references": {
29 | "destination_attribute": "id",
30 | "destination_attribute_default": null,
31 | "destination_attribute_generated": null,
32 | "multitenancy": {
33 | "attribute": null,
34 | "global": null,
35 | "strategy": null
36 | },
37 | "name": "friend_links_source_user_id_fkey",
38 | "on_delete": null,
39 | "on_update": null,
40 | "schema": "public",
41 | "table": "users"
42 | },
43 | "size": null,
44 | "source": "source_user_id",
45 | "type": "uuid"
46 | },
47 | {
48 | "allow_nil?": false,
49 | "default": "nil",
50 | "generated?": false,
51 | "primary_key?": false,
52 | "references": {
53 | "destination_attribute": "id",
54 | "destination_attribute_default": null,
55 | "destination_attribute_generated": null,
56 | "multitenancy": {
57 | "attribute": null,
58 | "global": null,
59 | "strategy": null
60 | },
61 | "name": "friend_links_destination_user_id_fkey",
62 | "on_delete": null,
63 | "on_update": null,
64 | "schema": "public",
65 | "table": "users"
66 | },
67 | "size": null,
68 | "source": "destination_user_id",
69 | "type": "uuid"
70 | }
71 | ],
72 | "base_filter": null,
73 | "check_constraints": [],
74 | "custom_indexes": [],
75 | "custom_statements": [],
76 | "has_create_action": true,
77 | "hash": "20AB6965B168E7C6E69FCA62C6D9ADCD29C6401CDD603ECBA962D65DBDBE2630",
78 | "identities": [
79 | {
80 | "base_filter": null,
81 | "index_name": "friend_links_unique_link_index",
82 | "keys": [
83 | "source_user_id",
84 | "destination_user_id"
85 | ],
86 | "name": "unique_link"
87 | }
88 | ],
89 | "multitenancy": {
90 | "attribute": null,
91 | "global": null,
92 | "strategy": null
93 | },
94 | "repo": "Elixir.Twitter.Repo",
95 | "schema": null,
96 | "table": "friend_links"
97 | }
--------------------------------------------------------------------------------
/priv/resource_snapshots/repo/likes/20230120180921.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?": 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": {
39 | "destination_attribute": "id",
40 | "destination_attribute_default": null,
41 | "destination_attribute_generated": null,
42 | "multitenancy": {
43 | "attribute": null,
44 | "global": null,
45 | "strategy": null
46 | },
47 | "name": "likes_user_id_fkey",
48 | "on_delete": "delete",
49 | "on_update": null,
50 | "schema": "public",
51 | "table": "users"
52 | },
53 | "size": null,
54 | "source": "user_id",
55 | "type": "uuid"
56 | },
57 | {
58 | "allow_nil?": false,
59 | "default": "nil",
60 | "generated?": false,
61 | "primary_key?": false,
62 | "references": {
63 | "destination_attribute": "id",
64 | "destination_attribute_default": null,
65 | "destination_attribute_generated": null,
66 | "multitenancy": {
67 | "attribute": null,
68 | "global": null,
69 | "strategy": null
70 | },
71 | "name": "likes_tweet_id_fkey",
72 | "on_delete": "delete",
73 | "on_update": null,
74 | "schema": "public",
75 | "table": "tweets"
76 | },
77 | "size": null,
78 | "source": "tweet_id",
79 | "type": "uuid"
80 | }
81 | ],
82 | "base_filter": null,
83 | "check_constraints": [],
84 | "custom_indexes": [],
85 | "custom_statements": [],
86 | "has_create_action": true,
87 | "hash": "C6CBD5D8FD30C06222575115C6C78DD8D2771035D400F1EFEC390B6598748386",
88 | "identities": [
89 | {
90 | "base_filter": null,
91 | "index_name": "likes_unique_user_and_tweet_index",
92 | "keys": [
93 | "user_id",
94 | "tweet_id"
95 | ],
96 | "name": "unique_user_and_tweet"
97 | }
98 | ],
99 | "multitenancy": {
100 | "attribute": null,
101 | "global": null,
102 | "strategy": null
103 | },
104 | "repo": "Elixir.Twitter.Repo",
105 | "schema": null,
106 | "table": "likes"
107 | }
--------------------------------------------------------------------------------
/priv/resource_snapshots/repo/tokens/20230120180921.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": "created_at",
11 | "type": "utc_datetime_usec"
12 | },
13 | {
14 | "allow_nil?": true,
15 | "default": "nil",
16 | "generated?": false,
17 | "primary_key?": false,
18 | "references": null,
19 | "size": null,
20 | "source": "extra_data",
21 | "type": "map"
22 | },
23 | {
24 | "allow_nil?": false,
25 | "default": "nil",
26 | "generated?": false,
27 | "primary_key?": false,
28 | "references": null,
29 | "size": null,
30 | "source": "purpose",
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": "expires_at",
41 | "type": "utc_datetime"
42 | },
43 | {
44 | "allow_nil?": false,
45 | "default": "nil",
46 | "generated?": false,
47 | "primary_key?": false,
48 | "references": null,
49 | "size": null,
50 | "source": "subject",
51 | "type": "text"
52 | },
53 | {
54 | "allow_nil?": false,
55 | "default": "nil",
56 | "generated?": false,
57 | "primary_key?": true,
58 | "references": null,
59 | "size": null,
60 | "source": "jti",
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": "id",
71 | "type": "uuid"
72 | },
73 | {
74 | "allow_nil?": false,
75 | "default": "fragment(\"now()\")",
76 | "generated?": false,
77 | "primary_key?": false,
78 | "references": null,
79 | "size": null,
80 | "source": "inserted_at",
81 | "type": "utc_datetime_usec"
82 | },
83 | {
84 | "allow_nil?": false,
85 | "default": "fragment(\"now()\")",
86 | "generated?": false,
87 | "primary_key?": false,
88 | "references": null,
89 | "size": null,
90 | "source": "updated_at",
91 | "type": "utc_datetime_usec"
92 | }
93 | ],
94 | "base_filter": null,
95 | "check_constraints": [],
96 | "custom_indexes": [],
97 | "custom_statements": [],
98 | "has_create_action": true,
99 | "hash": "F994110957623A572E5E6066386B3E993D816F0ACD0FBF199C4D72275AD4197E",
100 | "identities": [],
101 | "multitenancy": {
102 | "attribute": null,
103 | "global": null,
104 | "strategy": null
105 | },
106 | "repo": "Elixir.Twitter.Repo",
107 | "schema": null,
108 | "table": "tokens"
109 | }
--------------------------------------------------------------------------------
/priv/resource_snapshots/repo/tweets/20230120180921.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?": false,
15 | "default": "nil",
16 | "generated?": false,
17 | "primary_key?": false,
18 | "references": null,
19 | "size": null,
20 | "source": "text",
21 | "type": "text"
22 | },
23 | {
24 | "allow_nil?": true,
25 | "default": "nil",
26 | "generated?": false,
27 | "primary_key?": false,
28 | "references": null,
29 | "size": null,
30 | "source": "visibility",
31 | "type": "text"
32 | },
33 | {
34 | "allow_nil?": false,
35 | "default": "fragment(\"now()\")",
36 | "generated?": false,
37 | "primary_key?": false,
38 | "references": null,
39 | "size": null,
40 | "source": "inserted_at",
41 | "type": "utc_datetime_usec"
42 | },
43 | {
44 | "allow_nil?": false,
45 | "default": "fragment(\"now()\")",
46 | "generated?": false,
47 | "primary_key?": false,
48 | "references": null,
49 | "size": null,
50 | "source": "updated_at",
51 | "type": "utc_datetime_usec"
52 | },
53 | {
54 | "allow_nil?": false,
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": "tweets_author_id_fkey",
68 | "on_delete": null,
69 | "on_update": null,
70 | "schema": "public",
71 | "table": "users"
72 | },
73 | "size": null,
74 | "source": "author_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": "ECB60C89A0F0F959E66FD7D6A5C815E2507380272712710C3157289FD87BAF9F",
84 | "identities": [],
85 | "multitenancy": {
86 | "attribute": null,
87 | "global": null,
88 | "strategy": null
89 | },
90 | "repo": "Elixir.Twitter.Repo",
91 | "schema": null,
92 | "table": "tweets"
93 | }
--------------------------------------------------------------------------------
/priv/resource_snapshots/repo/users/20230120180921.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?": 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?": true,
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 | "allow_nil?": false,
35 | "default": "fragment(\"now()\")",
36 | "generated?": false,
37 | "primary_key?": false,
38 | "references": null,
39 | "size": null,
40 | "source": "inserted_at",
41 | "type": "utc_datetime_usec"
42 | },
43 | {
44 | "allow_nil?": false,
45 | "default": "fragment(\"now()\")",
46 | "generated?": false,
47 | "primary_key?": false,
48 | "references": null,
49 | "size": null,
50 | "source": "updated_at",
51 | "type": "utc_datetime_usec"
52 | }
53 | ],
54 | "base_filter": null,
55 | "check_constraints": [],
56 | "custom_indexes": [],
57 | "custom_statements": [],
58 | "has_create_action": true,
59 | "hash": "363C59E89125B96767615AA6A78B5006EE65DEDCC83967F490CD9AECA2B64374",
60 | "identities": [
61 | {
62 | "base_filter": null,
63 | "index_name": "users_unique_email_index",
64 | "keys": [
65 | "email"
66 | ],
67 | "name": "unique_email"
68 | }
69 | ],
70 | "multitenancy": {
71 | "attribute": null,
72 | "global": null,
73 | "strategy": null
74 | },
75 | "repo": "Elixir.Twitter.Repo",
76 | "schema": null,
77 | "table": "users"
78 | }
--------------------------------------------------------------------------------
/priv/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zachdaniel/twitter/372526f3fb71ea84a3143a058aedd6e2e3fe593d/priv/static/favicon.ico
--------------------------------------------------------------------------------
/priv/static/images/phoenix.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zachdaniel/twitter/372526f3fb71ea84a3143a058aedd6e2e3fe593d/priv/static/images/phoenix.png
--------------------------------------------------------------------------------
/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/support/conn_case.ex:
--------------------------------------------------------------------------------
1 | defmodule TwitterWeb.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 TwitterWeb.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 | # The default endpoint for testing
23 | @endpoint TwitterWeb.Endpoint
24 |
25 | use TwitterWeb, :verified_routes
26 |
27 | # Import conveniences for testing with connections
28 | import Plug.Conn
29 | import Phoenix.ConnTest
30 | import TwitterWeb.ConnCase
31 | end
32 | end
33 |
34 | setup tags do
35 | Twitter.DataCase.setup_sandbox(tags)
36 | {:ok, conn: Phoenix.ConnTest.build_conn()}
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/test/support/data_case.ex:
--------------------------------------------------------------------------------
1 | defmodule Twitter.DataCase do
2 | @moduledoc """
3 | This module defines the setup for tests requiring
4 | access to the application's data layer.
5 |
6 | You may define functions here to be used as helpers in
7 | your tests.
8 |
9 | Finally, if the test case interacts with the database,
10 | we enable the SQL sandbox, so changes done to the database
11 | are reverted at the end of every test. If you are using
12 | PostgreSQL, you can even run database tests asynchronously
13 | by setting `use Twitter.DataCase, async: true`, although
14 | this option is not recommended for other databases.
15 | """
16 |
17 | use ExUnit.CaseTemplate
18 |
19 | using do
20 | quote do
21 | alias Twitter.Repo
22 |
23 | import Ecto
24 | import Ecto.Changeset
25 | import Ecto.Query
26 | import Twitter.DataCase
27 | end
28 | end
29 |
30 | setup tags do
31 | Twitter.DataCase.setup_sandbox(tags)
32 | :ok
33 | end
34 |
35 | @doc """
36 | Sets up the sandbox based on the test tags.
37 | """
38 | def setup_sandbox(tags) do
39 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Twitter.Repo, shared: not tags[:async])
40 | on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
41 | end
42 |
43 | @doc """
44 | A helper that transforms changeset errors into a map of messages.
45 |
46 | assert {:error, changeset} = Accounts.create_user(%{password: "short"})
47 | assert "password is too short" in errors_on(changeset).password
48 | assert %{password: ["password is too short"]} = errors_on(changeset)
49 |
50 | """
51 | def errors_on(changeset) do
52 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
53 | Regex.replace(~r"%{(\w+)}", message, fn _, key ->
54 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
55 | end)
56 | end)
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/test/support/fixtures/tweets_fixtures.ex:
--------------------------------------------------------------------------------
1 | defmodule Twitter.TweetsFixtures do
2 | @moduledoc """
3 | This module defines test helpers for creating
4 | entities via the `Twitter.Tweets` context.
5 | """
6 |
7 | @doc """
8 | Generate a tweet.
9 | """
10 | def tweet_fixture(attrs \\ %{}) do
11 | {:ok, tweet} =
12 | attrs
13 | |> Enum.into(%{})
14 | |> Twitter.Tweets.create_tweet()
15 |
16 | tweet
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 | Ecto.Adapters.SQL.Sandbox.mode(Twitter.Repo, :manual)
3 |
--------------------------------------------------------------------------------
/test/twitter/tweets_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Twitter.TweetsTest do
2 | use Twitter.DataCase
3 |
4 | alias Twitter.Tweets
5 |
6 | describe "tweets" do
7 | alias Twitter.Tweets.Tweet
8 |
9 | import Twitter.TweetsFixtures
10 |
11 | @invalid_attrs %{}
12 |
13 | test "list_tweets/0 returns all tweets" do
14 | tweet = tweet_fixture()
15 | assert Tweets.list_tweets() == [tweet]
16 | end
17 |
18 | test "get_tweet!/1 returns the tweet with given id" do
19 | tweet = tweet_fixture()
20 | assert Tweets.get_tweet!(tweet.id) == tweet
21 | end
22 |
23 | test "create_tweet/1 with valid data creates a tweet" do
24 | valid_attrs = %{}
25 |
26 | assert {:ok, %Tweet{} = tweet} = Tweets.create_tweet(valid_attrs)
27 | end
28 |
29 | test "create_tweet/1 with invalid data returns error changeset" do
30 | assert {:error, %Ecto.Changeset{}} = Tweets.create_tweet(@invalid_attrs)
31 | end
32 |
33 | test "update_tweet/2 with valid data updates the tweet" do
34 | tweet = tweet_fixture()
35 | update_attrs = %{}
36 |
37 | assert {:ok, %Tweet{} = tweet} = Tweets.update_tweet(tweet, update_attrs)
38 | end
39 |
40 | test "update_tweet/2 with invalid data returns error changeset" do
41 | tweet = tweet_fixture()
42 | assert {:error, %Ecto.Changeset{}} = Tweets.update_tweet(tweet, @invalid_attrs)
43 | assert tweet == Tweets.get_tweet!(tweet.id)
44 | end
45 |
46 | test "delete_tweet/1 deletes the tweet" do
47 | tweet = tweet_fixture()
48 | assert {:ok, %Tweet{}} = Tweets.delete_tweet(tweet)
49 | assert_raise Ecto.NoResultsError, fn -> Tweets.get_tweet!(tweet.id) end
50 | end
51 |
52 | test "change_tweet/1 returns a tweet changeset" do
53 | tweet = tweet_fixture()
54 | assert %Ecto.Changeset{} = Tweets.change_tweet(tweet)
55 | end
56 | end
57 | end
58 |
--------------------------------------------------------------------------------
/test/twitter_web/controllers/error_html_test.exs:
--------------------------------------------------------------------------------
1 | defmodule TwitterWeb.ErrorHTMLTest do
2 | use TwitterWeb.ConnCase, async: true
3 |
4 | # Bring render_to_string/3 for testing custom views
5 | import Phoenix.Template
6 |
7 | test "renders 404.html" do
8 | assert render_to_string(TwitterWeb.ErrorHTML, "404", "html", []) == "Not Found"
9 | end
10 |
11 | test "renders 500.html" do
12 | assert render_to_string(TwitterWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/test/twitter_web/controllers/error_json_test.exs:
--------------------------------------------------------------------------------
1 | defmodule TwitterWeb.ErrorJSONTest do
2 | use TwitterWeb.ConnCase, async: true
3 |
4 | test "renders 404" do
5 | assert TwitterWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
6 | end
7 |
8 | test "renders 500" do
9 | assert TwitterWeb.ErrorJSON.render("500.json", %{}) ==
10 | %{errors: %{detail: "Internal Server Error"}}
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/test/twitter_web/controllers/page_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule TwitterWeb.PageControllerTest do
2 | use TwitterWeb.ConnCase
3 |
4 | test "GET /", %{conn: conn} do
5 | conn = get(conn, ~p"/")
6 | assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/test/twitter_web/live/tweet_live_test.exs:
--------------------------------------------------------------------------------
1 | defmodule TwitterWeb.TweetLiveTest do
2 | use TwitterWeb.ConnCase
3 |
4 | import Phoenix.LiveViewTest
5 | import Twitter.TweetsFixtures
6 |
7 | @create_attrs %{}
8 | @update_attrs %{}
9 | @invalid_attrs %{}
10 |
11 | defp create_tweet(_) do
12 | tweet = tweet_fixture()
13 | %{tweet: tweet}
14 | end
15 |
16 | describe "Index" do
17 | setup [:create_tweet]
18 |
19 | test "lists all tweets", %{conn: conn} do
20 | {:ok, _index_live, html} = live(conn, ~p"/tweets")
21 |
22 | assert html =~ "Listing Tweets"
23 | end
24 |
25 | test "saves new tweet", %{conn: conn} do
26 | {:ok, index_live, _html} = live(conn, ~p"/tweets")
27 |
28 | assert index_live |> element("a", "New Tweet") |> render_click() =~
29 | "New Tweet"
30 |
31 | assert_patch(index_live, ~p"/tweets/new")
32 |
33 | assert index_live
34 | |> form("#tweet-form", tweet: @invalid_attrs)
35 | |> render_change() =~ "can't be blank"
36 |
37 | {:ok, _, html} =
38 | index_live
39 | |> form("#tweet-form", tweet: @create_attrs)
40 | |> render_submit()
41 | |> follow_redirect(conn, ~p"/tweets")
42 |
43 | assert html =~ "Tweet created successfully"
44 | end
45 |
46 | test "updates tweet in listing", %{conn: conn, tweet: tweet} do
47 | {:ok, index_live, _html} = live(conn, ~p"/tweets")
48 |
49 | assert index_live |> element("#tweets-#{tweet.id} a", "Edit") |> render_click() =~
50 | "Edit Tweet"
51 |
52 | assert_patch(index_live, ~p"/tweets/#{tweet}/edit")
53 |
54 | assert index_live
55 | |> form("#tweet-form", tweet: @invalid_attrs)
56 | |> render_change() =~ "can't be blank"
57 |
58 | {:ok, _, html} =
59 | index_live
60 | |> form("#tweet-form", tweet: @update_attrs)
61 | |> render_submit()
62 | |> follow_redirect(conn, ~p"/tweets")
63 |
64 | assert html =~ "Tweet updated successfully"
65 | end
66 |
67 | test "deletes tweet in listing", %{conn: conn, tweet: tweet} do
68 | {:ok, index_live, _html} = live(conn, ~p"/tweets")
69 |
70 | assert index_live |> element("#tweets-#{tweet.id} a", "Delete") |> render_click()
71 | refute has_element?(index_live, "#tweet-#{tweet.id}")
72 | end
73 | end
74 |
75 | describe "Show" do
76 | setup [:create_tweet]
77 |
78 | test "displays tweet", %{conn: conn, tweet: tweet} do
79 | {:ok, _show_live, html} = live(conn, ~p"/tweets/#{tweet}")
80 |
81 | assert html =~ "Show Tweet"
82 | end
83 |
84 | test "updates tweet within modal", %{conn: conn, tweet: tweet} do
85 | {:ok, show_live, _html} = live(conn, ~p"/tweets/#{tweet}")
86 |
87 | assert show_live |> element("a", "Edit") |> render_click() =~
88 | "Edit Tweet"
89 |
90 | assert_patch(show_live, ~p"/tweets/#{tweet}/show/edit")
91 |
92 | assert show_live
93 | |> form("#tweet-form", tweet: @invalid_attrs)
94 | |> render_change() =~ "can't be blank"
95 |
96 | {:ok, _, html} =
97 | show_live
98 | |> form("#tweet-form", tweet: @update_attrs)
99 | |> render_submit()
100 | |> follow_redirect(conn, ~p"/tweets/#{tweet}")
101 |
102 | assert html =~ "Tweet updated successfully"
103 | end
104 | end
105 | end
106 |
--------------------------------------------------------------------------------