···11+# The directory Mix will write compiled artifacts to.
22+/_build/
33+44+# If you run "mix test --cover", coverage assets end up here.
55+/cover/
66+77+# The directory Mix downloads your dependencies sources to.
88+/deps/
99+1010+# Where 3rd-party dependencies like ExDoc output generated docs.
1111+/doc/
1212+1313+# Ignore .fetch files in case you like to edit your project deps locally.
1414+/.fetch
1515+1616+# If the VM crashes, it generates a dump, let's ignore it too.
1717+erl_crash.dump
1818+1919+# Also ignore archive artifacts (built via "mix archive.build").
2020+*.ez
2121+2222+# Temporary files, for example, from tests.
2323+/tmp/
2424+2525+# Ignore package tarball (built via "mix hex.build").
2626+annot_at-*.tar
2727+2828+# Ignore assets that are produced by build tools.
2929+/priv/static/assets/
3030+3131+# Ignore digested assets cache.
3232+/priv/static/cache_manifest.json
3333+3434+# In case you use Node.js/npm, you want to ignore these.
3535+npm-debug.log
3636+/assets/node_modules/
3737+
+18
README.md
···11+# AnnotAt
22+33+To start your Phoenix server:
44+55+* Run `mix setup` to install and setup dependencies
66+* Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
77+88+Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
99+1010+Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
1111+1212+## Learn more
1313+1414+* Official website: https://www.phoenixframework.org/
1515+* Guides: https://hexdocs.pm/phoenix/overview.html
1616+* Docs: https://hexdocs.pm/phoenix
1717+* Forum: https://elixirforum.com/c/phoenix-forum
1818+* Source: https://github.com/phoenixframework/phoenix
+105
assets/css/app.css
···11+/* See the Tailwind configuration guide for advanced usage
22+ https://tailwindcss.com/docs/configuration */
33+44+@import "tailwindcss" source(none);
55+@source "../css";
66+@source "../js";
77+@source "../../lib/annot_at_web";
88+99+/* A Tailwind plugin that makes "hero-#{ICON}" classes available.
1010+ The heroicons installation itself is managed by your mix.exs */
1111+@plugin "../vendor/heroicons";
1212+1313+/* daisyUI Tailwind Plugin. You can update this file by fetching the latest version with:
1414+ curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui.js
1515+ Make sure to look at the daisyUI changelog: https://daisyui.com/docs/changelog/ */
1616+@plugin "../vendor/daisyui" {
1717+ themes: false;
1818+}
1919+2020+/* daisyUI theme plugin. You can update this file by fetching the latest version with:
2121+ curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui-theme.js
2222+ We ship with two themes, a light one inspired on Phoenix colors and a dark one inspired
2323+ on Elixir colors. Build your own at: https://daisyui.com/theme-generator/ */
2424+@plugin "../vendor/daisyui-theme" {
2525+ name: "dark";
2626+ default: false;
2727+ prefersdark: true;
2828+ color-scheme: "dark";
2929+ --color-base-100: oklch(30.33% 0.016 252.42);
3030+ --color-base-200: oklch(25.26% 0.014 253.1);
3131+ --color-base-300: oklch(20.15% 0.012 254.09);
3232+ --color-base-content: oklch(97.807% 0.029 256.847);
3333+ --color-primary: oklch(58% 0.233 277.117);
3434+ --color-primary-content: oklch(96% 0.018 272.314);
3535+ --color-secondary: oklch(58% 0.233 277.117);
3636+ --color-secondary-content: oklch(96% 0.018 272.314);
3737+ --color-accent: oklch(60% 0.25 292.717);
3838+ --color-accent-content: oklch(96% 0.016 293.756);
3939+ --color-neutral: oklch(37% 0.044 257.287);
4040+ --color-neutral-content: oklch(98% 0.003 247.858);
4141+ --color-info: oklch(58% 0.158 241.966);
4242+ --color-info-content: oklch(97% 0.013 236.62);
4343+ --color-success: oklch(60% 0.118 184.704);
4444+ --color-success-content: oklch(98% 0.014 180.72);
4545+ --color-warning: oklch(66% 0.179 58.318);
4646+ --color-warning-content: oklch(98% 0.022 95.277);
4747+ --color-error: oklch(58% 0.253 17.585);
4848+ --color-error-content: oklch(96% 0.015 12.422);
4949+ --radius-selector: 0.25rem;
5050+ --radius-field: 0.25rem;
5151+ --radius-box: 0.5rem;
5252+ --size-selector: 0.21875rem;
5353+ --size-field: 0.21875rem;
5454+ --border: 1.5px;
5555+ --depth: 1;
5656+ --noise: 0;
5757+}
5858+5959+@plugin "../vendor/daisyui-theme" {
6060+ name: "light";
6161+ default: true;
6262+ prefersdark: false;
6363+ color-scheme: "light";
6464+ --color-base-100: oklch(98% 0 0);
6565+ --color-base-200: oklch(96% 0.001 286.375);
6666+ --color-base-300: oklch(92% 0.004 286.32);
6767+ --color-base-content: oklch(21% 0.006 285.885);
6868+ --color-primary: oklch(70% 0.213 47.604);
6969+ --color-primary-content: oklch(98% 0.016 73.684);
7070+ --color-secondary: oklch(55% 0.027 264.364);
7171+ --color-secondary-content: oklch(98% 0.002 247.839);
7272+ --color-accent: oklch(0% 0 0);
7373+ --color-accent-content: oklch(100% 0 0);
7474+ --color-neutral: oklch(44% 0.017 285.786);
7575+ --color-neutral-content: oklch(98% 0 0);
7676+ --color-info: oklch(62% 0.214 259.815);
7777+ --color-info-content: oklch(97% 0.014 254.604);
7878+ --color-success: oklch(70% 0.14 182.503);
7979+ --color-success-content: oklch(98% 0.014 180.72);
8080+ --color-warning: oklch(66% 0.179 58.318);
8181+ --color-warning-content: oklch(98% 0.022 95.277);
8282+ --color-error: oklch(58% 0.253 17.585);
8383+ --color-error-content: oklch(96% 0.015 12.422);
8484+ --radius-selector: 0.25rem;
8585+ --radius-field: 0.25rem;
8686+ --radius-box: 0.5rem;
8787+ --size-selector: 0.21875rem;
8888+ --size-field: 0.21875rem;
8989+ --border: 1.5px;
9090+ --depth: 1;
9191+ --noise: 0;
9292+}
9393+9494+/* Add variants based on LiveView classes */
9595+@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
9696+@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
9797+@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
9898+9999+/* Use the data attribute for dark mode */
100100+@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
101101+102102+/* Make LiveView wrapper divs transparent for layout */
103103+[data-phx-session], [data-phx-teleported-src] { display: contents }
104104+105105+/* This file is for your main application CSS */
+83
assets/js/app.js
···11+// If you want to use Phoenix channels, run `mix help phx.gen.channel`
22+// to get started and then uncomment the line below.
33+// import "./user_socket.js"
44+55+// You can include dependencies in two ways.
66+//
77+// The simplest option is to put them in assets/vendor and
88+// import them using relative paths:
99+//
1010+// import "../vendor/some-package.js"
1111+//
1212+// Alternatively, you can `npm install some-package --prefix assets` and import
1313+// them using a path starting with the package name:
1414+//
1515+// import "some-package"
1616+//
1717+// If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file.
1818+// To load it, simply add a second `<link>` to your `root.html.heex` file.
1919+2020+// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
2121+import "phoenix_html"
2222+// Establish Phoenix Socket and LiveView configuration.
2323+import {Socket} from "phoenix"
2424+import {LiveSocket} from "phoenix_live_view"
2525+import {hooks as colocatedHooks} from "phoenix-colocated/annot_at"
2626+import topbar from "../vendor/topbar"
2727+2828+const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
2929+const liveSocket = new LiveSocket("/live", Socket, {
3030+ longPollFallbackMs: 2500,
3131+ params: {_csrf_token: csrfToken},
3232+ hooks: {...colocatedHooks},
3333+})
3434+3535+// Show progress bar on live navigation and form submits
3636+topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
3737+window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
3838+window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
3939+4040+// connect if there are any LiveViews on the page
4141+liveSocket.connect()
4242+4343+// expose liveSocket on window for web console debug logs and latency simulation:
4444+// >> liveSocket.enableDebug()
4545+// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
4646+// >> liveSocket.disableLatencySim()
4747+window.liveSocket = liveSocket
4848+4949+// The lines below enable quality of life phoenix_live_reload
5050+// development features:
5151+//
5252+// 1. stream server logs to the browser console
5353+// 2. click on elements to jump to their definitions in your code editor
5454+//
5555+if (process.env.NODE_ENV === "development") {
5656+ window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => {
5757+ // Enable server log streaming to client.
5858+ // Disable with reloader.disableServerLogs()
5959+ reloader.enableServerLogs()
6060+6161+ // Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component
6262+ //
6363+ // * click with "c" key pressed to open at caller location
6464+ // * click with "d" key pressed to open at function component definition location
6565+ let keyDown
6666+ window.addEventListener("keydown", e => keyDown = e.key)
6767+ window.addEventListener("keyup", _e => keyDown = null)
6868+ window.addEventListener("click", e => {
6969+ if(keyDown === "c"){
7070+ e.preventDefault()
7171+ e.stopImmediatePropagation()
7272+ reloader.openEditorAtCaller(e.target)
7373+ } else if(keyDown === "d"){
7474+ e.preventDefault()
7575+ e.stopImmediatePropagation()
7676+ reloader.openEditorAtDef(e.target)
7777+ }
7878+ }, true)
7979+8080+ window.liveReloader = reloader
8181+ })
8282+}
8383+
+32
assets/tsconfig.json
···11+// This file is needed on most editors to enable the intelligent autocompletion
22+// of LiveView's JavaScript API methods. You can safely delete it if you don't need it.
33+//
44+// Note: This file assumes a basic esbuild setup without node_modules.
55+// We include a generic paths alias to deps to mimic how esbuild resolves
66+// the Phoenix and LiveView JavaScript assets.
77+// If you have a package.json in your project, you should remove the
88+// paths configuration and instead add the phoenix dependencies to the
99+// dependencies section of your package.json:
1010+//
1111+// {
1212+// ...
1313+// "dependencies": {
1414+// ...,
1515+// "phoenix": "../deps/phoenix",
1616+// "phoenix_html": "../deps/phoenix_html",
1717+// "phoenix_live_view": "../deps/phoenix_live_view"
1818+// }
1919+// }
2020+//
2121+// Feel free to adjust this configuration however you need.
2222+{
2323+ "compilerOptions": {
2424+ "baseUrl": ".",
2525+ "paths": {
2626+ "*": ["../deps/*"]
2727+ },
2828+ "allowJs": true,
2929+ "noEmit": true
3030+ },
3131+ "include": ["js/**/*"]
3232+}
···11+# This file is responsible for configuring your application
22+# and its dependencies with the aid of the Config module.
33+#
44+# This configuration file is loaded before any dependency and
55+# is restricted to this project.
66+77+# General application configuration
88+import Config
99+1010+config :annot_at,
1111+ ecto_repos: [AnnotAt.Repo],
1212+ generators: [timestamp_type: :utc_datetime]
1313+1414+# Configure the endpoint
1515+config :annot_at, AnnotAtWeb.Endpoint,
1616+ url: [host: "localhost"],
1717+ adapter: Bandit.PhoenixAdapter,
1818+ render_errors: [
1919+ formats: [html: AnnotAtWeb.ErrorHTML, json: AnnotAtWeb.ErrorJSON],
2020+ layout: false
2121+ ],
2222+ pubsub_server: AnnotAt.PubSub,
2323+ live_view: [signing_salt: "MAfe3gDq"]
2424+2525+# Configure the mailer
2626+#
2727+# By default it uses the "Local" adapter which stores the emails
2828+# locally. You can see the emails in your browser, at "/dev/mailbox".
2929+#
3030+# For production it's recommended to configure a different adapter
3131+# at the `config/runtime.exs`.
3232+config :annot_at, AnnotAt.Mailer, adapter: Swoosh.Adapters.Local
3333+3434+# Configure esbuild (the version is required)
3535+config :esbuild,
3636+ version: "0.25.4",
3737+ annot_at: [
3838+ args:
3939+ ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.),
4040+ cd: Path.expand("../assets", __DIR__),
4141+ env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
4242+ ]
4343+4444+# Configure tailwind (the version is required)
4545+config :tailwind,
4646+ version: "4.1.12",
4747+ annot_at: [
4848+ args: ~w(
4949+ --input=assets/css/app.css
5050+ --output=priv/static/assets/css/app.css
5151+ ),
5252+ cd: Path.expand("..", __DIR__)
5353+ ]
5454+5555+# Configure Elixir's Logger
5656+config :logger, :default_formatter,
5757+ format: "$time $metadata[$level] $message\n",
5858+ metadata: [:request_id]
5959+6060+# Use Jason for JSON parsing in Phoenix
6161+config :phoenix, :json_library, Jason
6262+6363+# Import environment specific config. This must remain at the bottom
6464+# of this file so it overrides the configuration defined above.
6565+import_config "#{config_env()}.exs"
+92
config/dev.exs
···11+import Config
22+33+# Configure your database
44+config :annot_at, AnnotAt.Repo,
55+ username: "postgres",
66+ password: "postgres",
77+ hostname: "localhost",
88+ database: "annot_at_dev",
99+ stacktrace: true,
1010+ show_sensitive_data_on_connection_error: true,
1111+ pool_size: 10
1212+1313+# For development, we disable any cache and enable
1414+# debugging and code reloading.
1515+#
1616+# The watchers configuration can be used to run external
1717+# watchers to your application. For example, we can use it
1818+# to bundle .js and .css sources.
1919+config :annot_at, AnnotAtWeb.Endpoint,
2020+ # Binding to loopback ipv4 address prevents access from other machines.
2121+ # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
2222+ http: [ip: {127, 0, 0, 1}],
2323+ check_origin: false,
2424+ code_reloader: true,
2525+ debug_errors: true,
2626+ secret_key_base: "ZYZP/RP/olnXZM6S9Yg1IhPX85tKqTAFxno8oqSUuosAGtQKJfkWaHSOgtsuZML9",
2727+ watchers: [
2828+ esbuild: {Esbuild, :install_and_run, [:annot_at, ~w(--sourcemap=inline --watch)]},
2929+ tailwind: {Tailwind, :install_and_run, [:annot_at, ~w(--watch)]}
3030+ ]
3131+3232+# ## SSL Support
3333+#
3434+# In order to use HTTPS in development, a self-signed
3535+# certificate can be generated by running the following
3636+# Mix task:
3737+#
3838+# mix phx.gen.cert
3939+#
4040+# Run `mix help phx.gen.cert` for more information.
4141+#
4242+# The `http:` config above can be replaced with:
4343+#
4444+# https: [
4545+# port: 4001,
4646+# cipher_suite: :strong,
4747+# keyfile: "priv/cert/selfsigned_key.pem",
4848+# certfile: "priv/cert/selfsigned.pem"
4949+# ],
5050+#
5151+# If desired, both `http:` and `https:` keys can be
5252+# configured to run both http and https servers on
5353+# different ports.
5454+5555+# Reload browser tabs when matching files change.
5656+config :annot_at, AnnotAtWeb.Endpoint,
5757+ live_reload: [
5858+ web_console_logger: true,
5959+ patterns: [
6060+ # Static assets, except user uploads
6161+ ~r"priv/static/(?!uploads/).*\.(js|css|png|jpeg|jpg|gif|svg)$"E,
6262+ # Gettext translations
6363+ ~r"priv/gettext/.*\.po$"E,
6464+ # Router, Controllers, LiveViews and LiveComponents
6565+ ~r"lib/annot_at_web/router\.ex$"E,
6666+ ~r"lib/annot_at_web/(controllers|live|components)/.*\.(ex|heex)$"E
6767+ ]
6868+ ]
6969+7070+# Enable dev routes for dashboard and mailbox
7171+config :annot_at, dev_routes: true
7272+7373+# Do not include metadata nor timestamps in development logs
7474+config :logger, :default_formatter, format: "[$level] $message\n"
7575+7676+# Set a higher stacktrace during development. Avoid configuring such
7777+# in production as building large stacktraces may be expensive.
7878+config :phoenix, :stacktrace_depth, 20
7979+8080+# Initialize plugs at runtime for faster development compilation
8181+config :phoenix, :plug_init_mode, :runtime
8282+8383+config :phoenix_live_view,
8484+ # Include debug annotations and locations in rendered markup.
8585+ # Changing this configuration will require mix clean and a full recompile.
8686+ debug_heex_annotations: true,
8787+ debug_attributes: true,
8888+ # Enable helpful, but potentially expensive runtime checks
8989+ enable_expensive_runtime_checks: true
9090+9191+# Disable swoosh api client as it is only required for production adapters.
9292+config :swoosh, :api_client, false
+32
config/prod.exs
···11+import Config
22+33+# Note we also include the path to a cache manifest
44+# containing the digested version of static files. This
55+# manifest is generated by the `mix assets.deploy` task,
66+# which you should run after static files are built and
77+# before starting your production server.
88+config :annot_at, AnnotAtWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
99+1010+# Force using SSL in production. This also sets the "strict-security-transport" header,
1111+# known as HSTS. If you have a health check endpoint, you may want to exclude it below.
1212+# Note `:force_ssl` is required to be set at compile-time.
1313+config :annot_at, AnnotAtWeb.Endpoint,
1414+ force_ssl: [
1515+ rewrite_on: [:x_forwarded_proto],
1616+ exclude: [
1717+ # paths: ["/health"],
1818+ hosts: ["localhost", "127.0.0.1"]
1919+ ]
2020+ ]
2121+2222+# Configure Swoosh API Client
2323+config :swoosh, api_client: Swoosh.ApiClient.Req
2424+2525+# Disable Swoosh Local Memory Storage
2626+config :swoosh, local: false
2727+2828+# Do not print debug messages in production
2929+config :logger, level: :info
3030+3131+# Runtime production configuration, including reading
3232+# of environment variables, is done on config/runtime.exs.
+120
config/runtime.exs
···11+import Config
22+33+# config/runtime.exs is executed for all environments, including
44+# during releases. It is executed after compilation and before the
55+# system starts, so it is typically used to load production configuration
66+# and secrets from environment variables or elsewhere. Do not define
77+# any compile-time configuration in here, as it won't be applied.
88+# The block below contains prod specific runtime configuration.
99+1010+# ## Using releases
1111+#
1212+# If you use `mix release`, you need to explicitly enable the server
1313+# by passing the PHX_SERVER=true when you start it:
1414+#
1515+# PHX_SERVER=true bin/annot_at start
1616+#
1717+# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
1818+# script that automatically sets the env var above.
1919+if System.get_env("PHX_SERVER") do
2020+ config :annot_at, AnnotAtWeb.Endpoint, server: true
2121+end
2222+2323+config :annot_at, AnnotAtWeb.Endpoint,
2424+ http: [port: String.to_integer(System.get_env("PORT", "4000"))]
2525+2626+if config_env() == :prod do
2727+ database_url =
2828+ System.get_env("DATABASE_URL") ||
2929+ raise """
3030+ environment variable DATABASE_URL is missing.
3131+ For example: ecto://USER:PASS@HOST/DATABASE
3232+ """
3333+3434+ maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
3535+3636+ config :annot_at, AnnotAt.Repo,
3737+ # ssl: true,
3838+ url: database_url,
3939+ pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
4040+ # For machines with several cores, consider starting multiple pools of `pool_size`
4141+ # pool_count: 4,
4242+ socket_options: maybe_ipv6
4343+4444+ # The secret key base is used to sign/encrypt cookies and other secrets.
4545+ # A default value is used in config/dev.exs and config/test.exs but you
4646+ # want to use a different value for prod and you most likely don't want
4747+ # to check this value into version control, so we use an environment
4848+ # variable instead.
4949+ secret_key_base =
5050+ System.get_env("SECRET_KEY_BASE") ||
5151+ raise """
5252+ environment variable SECRET_KEY_BASE is missing.
5353+ You can generate one by calling: mix phx.gen.secret
5454+ """
5555+5656+ host = System.get_env("PHX_HOST") || "example.com"
5757+5858+ config :annot_at, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
5959+6060+ config :annot_at, AnnotAtWeb.Endpoint,
6161+ url: [host: host, port: 443, scheme: "https"],
6262+ http: [
6363+ # Enable IPv6 and bind on all interfaces.
6464+ # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
6565+ # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
6666+ # for details about using IPv6 vs IPv4 and loopback vs public addresses.
6767+ ip: {0, 0, 0, 0, 0, 0, 0, 0}
6868+ ],
6969+ secret_key_base: secret_key_base
7070+7171+ # ## SSL Support
7272+ #
7373+ # To get SSL working, you will need to add the `https` key
7474+ # to your endpoint configuration:
7575+ #
7676+ # config :annot_at, AnnotAtWeb.Endpoint,
7777+ # https: [
7878+ # ...,
7979+ # port: 443,
8080+ # cipher_suite: :strong,
8181+ # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
8282+ # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
8383+ # ]
8484+ #
8585+ # The `cipher_suite` is set to `:strong` to support only the
8686+ # latest and more secure SSL ciphers. This means old browsers
8787+ # and clients may not be supported. You can set it to
8888+ # `:compatible` for wider support.
8989+ #
9090+ # `:keyfile` and `:certfile` expect an absolute path to the key
9191+ # and cert in disk or a relative path inside priv, for example
9292+ # "priv/ssl/server.key". For all supported SSL configuration
9393+ # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
9494+ #
9595+ # We also recommend setting `force_ssl` in your config/prod.exs,
9696+ # ensuring no data is ever sent via http, always redirecting to https:
9797+ #
9898+ # config :annot_at, AnnotAtWeb.Endpoint,
9999+ # force_ssl: [hsts: true]
100100+ #
101101+ # Check `Plug.SSL` for all available options in `force_ssl`.
102102+103103+ # ## Configuring the mailer
104104+ #
105105+ # In production you need to configure the mailer to use a different adapter.
106106+ # Here is an example configuration for Mailgun:
107107+ #
108108+ # config :annot_at, AnnotAt.Mailer,
109109+ # adapter: Swoosh.Adapters.Mailgun,
110110+ # api_key: System.get_env("MAILGUN_API_KEY"),
111111+ # domain: System.get_env("MAILGUN_DOMAIN")
112112+ #
113113+ # Most non-SMTP adapters require an API client. Swoosh supports Req, Hackney,
114114+ # and Finch out-of-the-box. This configuration is typically done at
115115+ # compile-time in your config/prod.exs:
116116+ #
117117+ # config :swoosh, :api_client, Swoosh.ApiClient.Req
118118+ #
119119+ # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
120120+end
+41
config/test.exs
···11+import Config
22+33+# Configure your database
44+#
55+# The MIX_TEST_PARTITION environment variable can be used
66+# to provide built-in test partitioning in CI environment.
77+# Run `mix help test` for more information.
88+config :annot_at, AnnotAt.Repo,
99+ username: "postgres",
1010+ password: "postgres",
1111+ hostname: "localhost",
1212+ database: "annot_at_test#{System.get_env("MIX_TEST_PARTITION")}",
1313+ pool: Ecto.Adapters.SQL.Sandbox,
1414+ pool_size: System.schedulers_online() * 2
1515+1616+# We don't run a server during test. If one is required,
1717+# you can enable the server option below.
1818+config :annot_at, AnnotAtWeb.Endpoint,
1919+ http: [ip: {127, 0, 0, 1}, port: 4002],
2020+ secret_key_base: "rBRlM0XkcGU9vvPaZBoqQSeMHae+l7otu8+sy42WgsjfDi0J2RLCcxjajV8UfZEb",
2121+ server: false
2222+2323+# In test we don't send emails
2424+config :annot_at, AnnotAt.Mailer, adapter: Swoosh.Adapters.Test
2525+2626+# Disable swoosh api client as it is only required for production adapters
2727+config :swoosh, :api_client, false
2828+2929+# Print only warnings and errors during test
3030+config :logger, level: :warning
3131+3232+# Initialize plugs at runtime for faster test compilation
3333+config :phoenix, :plug_init_mode, :runtime
3434+3535+# Enable helpful, but potentially expensive runtime checks
3636+config :phoenix_live_view,
3737+ enable_expensive_runtime_checks: true
3838+3939+# Sort query params output of verified routes for robust url comparisons
4040+config :phoenix,
4141+ sort_verified_routes_query_params: true
+9
lib/annot_at.ex
···11+defmodule AnnotAt do
22+ @moduledoc """
33+ AnnotAt keeps the contexts that define your domain
44+ and business logic.
55+66+ Contexts are also responsible for managing your data, regardless
77+ if it comes from the database, an external API or others.
88+ """
99+end
+34
lib/annot_at/application.ex
···11+defmodule AnnotAt.Application do
22+ # See https://hexdocs.pm/elixir/Application.html
33+ # for more information on OTP Applications
44+ @moduledoc false
55+66+ use Application
77+88+ @impl true
99+ def start(_type, _args) do
1010+ children = [
1111+ AnnotAtWeb.Telemetry,
1212+ AnnotAt.Repo,
1313+ {DNSCluster, query: Application.get_env(:annot_at, :dns_cluster_query) || :ignore},
1414+ {Phoenix.PubSub, name: AnnotAt.PubSub},
1515+ # Start a worker by calling: AnnotAt.Worker.start_link(arg)
1616+ # {AnnotAt.Worker, arg},
1717+ # Start to serve requests, typically the last entry
1818+ AnnotAtWeb.Endpoint
1919+ ]
2020+2121+ # See https://hexdocs.pm/elixir/Supervisor.html
2222+ # for other strategies and supported options
2323+ opts = [strategy: :one_for_one, name: AnnotAt.Supervisor]
2424+ Supervisor.start_link(children, opts)
2525+ end
2626+2727+ # Tell Phoenix to update the endpoint configuration
2828+ # whenever the application is updated.
2929+ @impl true
3030+ def config_change(changed, _new, removed) do
3131+ AnnotAtWeb.Endpoint.config_change(changed, removed)
3232+ :ok
3333+ end
3434+end
+3
lib/annot_at/mailer.ex
···11+defmodule AnnotAt.Mailer do
22+ use Swoosh.Mailer, otp_app: :annot_at
33+end
+5
lib/annot_at/repo.ex
···11+defmodule AnnotAt.Repo do
22+ use Ecto.Repo,
33+ otp_app: :annot_at,
44+ adapter: Ecto.Adapters.Postgres
55+end
+114
lib/annot_at_web.ex
···11+defmodule AnnotAtWeb do
22+ @moduledoc """
33+ The entrypoint for defining your web interface, such
44+ as controllers, components, channels, and so on.
55+66+ This can be used in your application as:
77+88+ use AnnotAtWeb, :controller
99+ use AnnotAtWeb, :html
1010+1111+ The definitions below will be executed for every controller,
1212+ component, etc, so keep them short and clean, focused
1313+ on imports, uses and aliases.
1414+1515+ Do NOT define functions inside the quoted expressions
1616+ below. Instead, define additional modules and import
1717+ those modules here.
1818+ """
1919+2020+ def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
2121+2222+ def router do
2323+ quote do
2424+ use Phoenix.Router, helpers: false
2525+2626+ # Import common connection and controller functions to use in pipelines
2727+ import Plug.Conn
2828+ import Phoenix.Controller
2929+ import Phoenix.LiveView.Router
3030+ end
3131+ end
3232+3333+ def channel do
3434+ quote do
3535+ use Phoenix.Channel
3636+ end
3737+ end
3838+3939+ def controller do
4040+ quote do
4141+ use Phoenix.Controller, formats: [:html, :json]
4242+4343+ use Gettext, backend: AnnotAtWeb.Gettext
4444+4545+ import Plug.Conn
4646+4747+ unquote(verified_routes())
4848+ end
4949+ end
5050+5151+ def live_view do
5252+ quote do
5353+ use Phoenix.LiveView
5454+5555+ unquote(html_helpers())
5656+ end
5757+ end
5858+5959+ def live_component do
6060+ quote do
6161+ use Phoenix.LiveComponent
6262+6363+ unquote(html_helpers())
6464+ end
6565+ end
6666+6767+ def html do
6868+ quote do
6969+ use Phoenix.Component
7070+7171+ # Import convenience functions from controllers
7272+ import Phoenix.Controller,
7373+ only: [get_csrf_token: 0, view_module: 1, view_template: 1]
7474+7575+ # Include general helpers for rendering HTML
7676+ unquote(html_helpers())
7777+ end
7878+ end
7979+8080+ defp html_helpers do
8181+ quote do
8282+ # Translation
8383+ use Gettext, backend: AnnotAtWeb.Gettext
8484+8585+ # HTML escaping functionality
8686+ import Phoenix.HTML
8787+ # Core UI components
8888+ import AnnotAtWeb.CoreComponents
8989+9090+ # Common modules used in templates
9191+ alias Phoenix.LiveView.JS
9292+ alias AnnotAtWeb.Layouts
9393+9494+ # Routes generation with the ~p sigil
9595+ unquote(verified_routes())
9696+ end
9797+ end
9898+9999+ def verified_routes do
100100+ quote do
101101+ use Phoenix.VerifiedRoutes,
102102+ endpoint: AnnotAtWeb.Endpoint,
103103+ router: AnnotAtWeb.Router,
104104+ statics: AnnotAtWeb.static_paths()
105105+ end
106106+ end
107107+108108+ @doc """
109109+ When used, dispatch to the appropriate controller/live_view/etc.
110110+ """
111111+ defmacro __using__(which) when is_atom(which) do
112112+ apply(__MODULE__, which, [])
113113+ end
114114+end
+505
lib/annot_at_web/components/core_components.ex
···11+defmodule AnnotAtWeb.CoreComponents do
22+ @moduledoc """
33+ Provides core UI components.
44+55+ At first glance, this module may seem daunting, but its goal is to provide
66+ core building blocks for your application, such as tables, forms, and
77+ inputs. The components consist mostly of markup and are well-documented
88+ with doc strings and declarative assigns. You may customize and style
99+ them in any way you want, based on your application growth and needs.
1010+1111+ The foundation for styling is Tailwind CSS, a utility-first CSS framework,
1212+ augmented with daisyUI, a Tailwind CSS plugin that provides UI components
1313+ and themes. Here are useful references:
1414+1515+ * [daisyUI](https://daisyui.com/docs/intro/) - a good place to get
1616+ started and see the available components.
1717+1818+ * [Tailwind CSS](https://tailwindcss.com) - the foundational framework
1919+ we build on. You will use it for layout, sizing, flexbox, grid, and
2020+ spacing.
2121+2222+ * [Heroicons](https://heroicons.com) - see `icon/1` for usage.
2323+2424+ * [Phoenix.Component](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html) -
2525+ the component system used by Phoenix. Some components, such as `<.link>`
2626+ and `<.form>`, are defined there.
2727+2828+ """
2929+ use Phoenix.Component
3030+ use Gettext, backend: AnnotAtWeb.Gettext
3131+3232+ alias Phoenix.LiveView.JS
3333+3434+ @doc """
3535+ Renders flash notices.
3636+3737+ ## Examples
3838+3939+ <.flash kind={:info} flash={@flash} />
4040+ <.flash
4141+ id="welcome-back"
4242+ kind={:info}
4343+ phx-mounted={show("#welcome-back") |> JS.remove_attribute("hidden")}
4444+ hidden
4545+ >
4646+ Welcome Back!
4747+ </.flash>
4848+ """
4949+ attr :id, :string, doc: "the optional id of flash container"
5050+ attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
5151+ attr :title, :string, default: nil
5252+ attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
5353+ attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
5454+5555+ slot :inner_block, doc: "the optional inner block that renders the flash message"
5656+5757+ def flash(assigns) do
5858+ assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
5959+6060+ ~H"""
6161+ <div
6262+ :if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
6363+ id={@id}
6464+ phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
6565+ role="alert"
6666+ class="toast toast-top toast-end z-50"
6767+ {@rest}
6868+ >
6969+ <div class={[
7070+ "alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
7171+ @kind == :info && "alert-info",
7272+ @kind == :error && "alert-error"
7373+ ]}>
7474+ <.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
7575+ <.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
7676+ <div>
7777+ <p :if={@title} class="font-semibold">{@title}</p>
7878+ <p>{msg}</p>
7979+ </div>
8080+ <div class="flex-1" />
8181+ <button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
8282+ <.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
8383+ </button>
8484+ </div>
8585+ </div>
8686+ """
8787+ end
8888+8989+ @doc """
9090+ Renders a button with navigation support.
9191+9292+ ## Examples
9393+9494+ <.button>Send!</.button>
9595+ <.button phx-click="go" variant="primary">Send!</.button>
9696+ <.button navigate={~p"/"}>Home</.button>
9797+ """
9898+ attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
9999+ attr :class, :any
100100+ attr :variant, :string, values: ~w(primary)
101101+ slot :inner_block, required: true
102102+103103+ def button(%{rest: rest} = assigns) do
104104+ variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
105105+106106+ assigns =
107107+ assign_new(assigns, :class, fn ->
108108+ ["btn", Map.fetch!(variants, assigns[:variant])]
109109+ end)
110110+111111+ if rest[:href] || rest[:navigate] || rest[:patch] do
112112+ ~H"""
113113+ <.link class={@class} {@rest}>
114114+ {render_slot(@inner_block)}
115115+ </.link>
116116+ """
117117+ else
118118+ ~H"""
119119+ <button class={@class} {@rest}>
120120+ {render_slot(@inner_block)}
121121+ </button>
122122+ """
123123+ end
124124+ end
125125+126126+ @doc """
127127+ Renders an input with label and error messages.
128128+129129+ A `Phoenix.HTML.FormField` may be passed as argument,
130130+ which is used to retrieve the input name, id, and values.
131131+ Otherwise all attributes may be passed explicitly.
132132+133133+ ## Types
134134+135135+ This function accepts all HTML input types, considering that:
136136+137137+ * You may also set `type="select"` to render a `<select>` tag
138138+139139+ * `type="checkbox"` is used exclusively to render boolean values
140140+141141+ * For live file uploads, see `Phoenix.Component.live_file_input/1`
142142+143143+ See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
144144+ for more information. Unsupported types, such as radio, are best
145145+ written directly in your templates.
146146+147147+ ## Examples
148148+149149+ ```heex
150150+ <.input field={@form[:email]} type="email" />
151151+ <.input name="my-input" errors={["oh no!"]} />
152152+ ```
153153+154154+ ## Select type
155155+156156+ When using `type="select"`, you must pass the `options` and optionally
157157+ a `value` to mark which option should be preselected.
158158+159159+ ```heex
160160+ <.input field={@form[:user_type]} type="select" options={["Admin": "admin", "User": "user"]} />
161161+ ```
162162+163163+ For more information on what kind of data can be passed to `options` see
164164+ [`options_for_select`](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#options_for_select/2).
165165+ """
166166+ attr :id, :any, default: nil
167167+ attr :name, :any
168168+ attr :label, :string, default: nil
169169+ attr :value, :any
170170+171171+ attr :type, :string,
172172+ default: "text",
173173+ values: ~w(checkbox color date datetime-local email file month number password
174174+ search select tel text textarea time url week hidden)
175175+176176+ attr :field, Phoenix.HTML.FormField,
177177+ doc: "a form field struct retrieved from the form, for example: @form[:email]"
178178+179179+ attr :errors, :list, default: []
180180+ attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
181181+ attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
182182+ attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
183183+ attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
184184+ attr :class, :any, default: nil, doc: "the input class to use over defaults"
185185+ attr :error_class, :any, default: nil, doc: "the input error class to use over defaults"
186186+187187+ attr :rest, :global,
188188+ include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
189189+ multiple pattern placeholder readonly required rows size step)
190190+191191+ def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
192192+ errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
193193+194194+ assigns
195195+ |> assign(field: nil, id: assigns.id || field.id)
196196+ |> assign(:errors, Enum.map(errors, &translate_error(&1)))
197197+ |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
198198+ |> assign_new(:value, fn -> field.value end)
199199+ |> input()
200200+ end
201201+202202+ def input(%{type: "hidden"} = assigns) do
203203+ ~H"""
204204+ <input type="hidden" id={@id} name={@name} value={@value} {@rest} />
205205+ """
206206+ end
207207+208208+ def input(%{type: "checkbox"} = assigns) do
209209+ assigns =
210210+ assign_new(assigns, :checked, fn ->
211211+ Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
212212+ end)
213213+214214+ ~H"""
215215+ <div class="fieldset mb-2">
216216+ <label for={@id}>
217217+ <input
218218+ type="hidden"
219219+ name={@name}
220220+ value="false"
221221+ disabled={@rest[:disabled]}
222222+ form={@rest[:form]}
223223+ />
224224+ <span class="label">
225225+ <input
226226+ type="checkbox"
227227+ id={@id}
228228+ name={@name}
229229+ value="true"
230230+ checked={@checked}
231231+ class={@class || "checkbox checkbox-sm"}
232232+ {@rest}
233233+ />{@label}
234234+ </span>
235235+ </label>
236236+ <.error :for={msg <- @errors}>{msg}</.error>
237237+ </div>
238238+ """
239239+ end
240240+241241+ def input(%{type: "select"} = assigns) do
242242+ ~H"""
243243+ <div class="fieldset mb-2">
244244+ <label for={@id}>
245245+ <span :if={@label} class="label mb-1">{@label}</span>
246246+ <select
247247+ id={@id}
248248+ name={@name}
249249+ class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
250250+ multiple={@multiple}
251251+ {@rest}
252252+ >
253253+ <option :if={@prompt} value="">{@prompt}</option>
254254+ {Phoenix.HTML.Form.options_for_select(@options, @value)}
255255+ </select>
256256+ </label>
257257+ <.error :for={msg <- @errors}>{msg}</.error>
258258+ </div>
259259+ """
260260+ end
261261+262262+ def input(%{type: "textarea"} = assigns) do
263263+ ~H"""
264264+ <div class="fieldset mb-2">
265265+ <label for={@id}>
266266+ <span :if={@label} class="label mb-1">{@label}</span>
267267+ <textarea
268268+ id={@id}
269269+ name={@name}
270270+ class={[
271271+ @class || "w-full textarea",
272272+ @errors != [] && (@error_class || "textarea-error")
273273+ ]}
274274+ {@rest}
275275+ >{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
276276+ </label>
277277+ <.error :for={msg <- @errors}>{msg}</.error>
278278+ </div>
279279+ """
280280+ end
281281+282282+ # All other inputs text, datetime-local, url, password, etc. are handled here...
283283+ def input(assigns) do
284284+ ~H"""
285285+ <div class="fieldset mb-2">
286286+ <label for={@id}>
287287+ <span :if={@label} class="label mb-1">{@label}</span>
288288+ <input
289289+ type={@type}
290290+ name={@name}
291291+ id={@id}
292292+ value={Phoenix.HTML.Form.normalize_value(@type, @value)}
293293+ class={[
294294+ @class || "w-full input",
295295+ @errors != [] && (@error_class || "input-error")
296296+ ]}
297297+ {@rest}
298298+ />
299299+ </label>
300300+ <.error :for={msg <- @errors}>{msg}</.error>
301301+ </div>
302302+ """
303303+ end
304304+305305+ # Helper used by inputs to generate form errors
306306+ defp error(assigns) do
307307+ ~H"""
308308+ <p class="mt-1.5 flex gap-2 items-center text-sm text-error">
309309+ <.icon name="hero-exclamation-circle" class="size-5" />
310310+ {render_slot(@inner_block)}
311311+ </p>
312312+ """
313313+ end
314314+315315+ @doc """
316316+ Renders a header with title.
317317+ """
318318+ slot :inner_block, required: true
319319+ slot :subtitle
320320+ slot :actions
321321+322322+ def header(assigns) do
323323+ ~H"""
324324+ <header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
325325+ <div>
326326+ <h1 class="text-lg font-semibold leading-8">
327327+ {render_slot(@inner_block)}
328328+ </h1>
329329+ <p :if={@subtitle != []} class="text-sm text-base-content/70">
330330+ {render_slot(@subtitle)}
331331+ </p>
332332+ </div>
333333+ <div class="flex-none">{render_slot(@actions)}</div>
334334+ </header>
335335+ """
336336+ end
337337+338338+ @doc """
339339+ Renders a table with generic styling.
340340+341341+ ## Examples
342342+343343+ <.table id="users" rows={@users}>
344344+ <:col :let={user} label="id">{user.id}</:col>
345345+ <:col :let={user} label="username">{user.username}</:col>
346346+ </.table>
347347+ """
348348+ attr :id, :string, required: true
349349+ attr :rows, :list, required: true
350350+ attr :row_id, :any, default: nil, doc: "the function for generating the row id"
351351+ attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
352352+353353+ attr :row_item, :any,
354354+ default: &Function.identity/1,
355355+ doc: "the function for mapping each row before calling the :col and :action slots"
356356+357357+ slot :col, required: true do
358358+ attr :label, :string
359359+ end
360360+361361+ slot :action, doc: "the slot for showing user actions in the last table column"
362362+363363+ def table(assigns) do
364364+ assigns =
365365+ with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
366366+ assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
367367+ end
368368+369369+ ~H"""
370370+ <table class="table table-zebra">
371371+ <thead>
372372+ <tr>
373373+ <th :for={col <- @col}>{col[:label]}</th>
374374+ <th :if={@action != []}>
375375+ <span class="sr-only">{gettext("Actions")}</span>
376376+ </th>
377377+ </tr>
378378+ </thead>
379379+ <tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
380380+ <tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
381381+ <td
382382+ :for={col <- @col}
383383+ phx-click={@row_click && @row_click.(row)}
384384+ class={@row_click && "hover:cursor-pointer"}
385385+ >
386386+ {render_slot(col, @row_item.(row))}
387387+ </td>
388388+ <td :if={@action != []} class="w-0 font-semibold">
389389+ <div class="flex gap-4">
390390+ <%= for action <- @action do %>
391391+ {render_slot(action, @row_item.(row))}
392392+ <% end %>
393393+ </div>
394394+ </td>
395395+ </tr>
396396+ </tbody>
397397+ </table>
398398+ """
399399+ end
400400+401401+ @doc """
402402+ Renders a data list.
403403+404404+ ## Examples
405405+406406+ <.list>
407407+ <:item title="Title">{@post.title}</:item>
408408+ <:item title="Views">{@post.views}</:item>
409409+ </.list>
410410+ """
411411+ slot :item, required: true do
412412+ attr :title, :string, required: true
413413+ end
414414+415415+ def list(assigns) do
416416+ ~H"""
417417+ <ul class="list">
418418+ <li :for={item <- @item} class="list-row">
419419+ <div class="list-col-grow">
420420+ <div class="font-bold">{item.title}</div>
421421+ <div>{render_slot(item)}</div>
422422+ </div>
423423+ </li>
424424+ </ul>
425425+ """
426426+ end
427427+428428+ @doc """
429429+ Renders a [Heroicon](https://heroicons.com).
430430+431431+ Heroicons come in three styles – outline, solid, and mini.
432432+ By default, the outline style is used, but solid and mini may
433433+ be applied by using the `-solid` and `-mini` suffix.
434434+435435+ You can customize the size and colors of the icons by setting
436436+ width, height, and background color classes.
437437+438438+ Icons are extracted from the `deps/heroicons` directory and bundled within
439439+ your compiled app.css by the plugin in `assets/vendor/heroicons.js`.
440440+441441+ ## Examples
442442+443443+ <.icon name="hero-x-mark" />
444444+ <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
445445+ """
446446+ attr :name, :string, required: true
447447+ attr :class, :any, default: "size-4"
448448+449449+ def icon(%{name: "hero-" <> _} = assigns) do
450450+ ~H"""
451451+ <span class={[@name, @class]} />
452452+ """
453453+ end
454454+455455+ ## JS Commands
456456+457457+ def show(js \\ %JS{}, selector) do
458458+ JS.show(js,
459459+ to: selector,
460460+ time: 300,
461461+ transition:
462462+ {"transition-all ease-out duration-300",
463463+ "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
464464+ "opacity-100 translate-y-0 sm:scale-100"}
465465+ )
466466+ end
467467+468468+ def hide(js \\ %JS{}, selector) do
469469+ JS.hide(js,
470470+ to: selector,
471471+ time: 200,
472472+ transition:
473473+ {"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
474474+ "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
475475+ )
476476+ end
477477+478478+ @doc """
479479+ Translates an error message using gettext.
480480+ """
481481+ def translate_error({msg, opts}) do
482482+ # When using gettext, we typically pass the strings we want
483483+ # to translate as a static argument:
484484+ #
485485+ # # Translate the number of files with plural rules
486486+ # dngettext("errors", "1 file", "%{count} files", count)
487487+ #
488488+ # However the error messages in our forms and APIs are generated
489489+ # dynamically, so we need to translate them by calling Gettext
490490+ # with our gettext backend as first argument. Translations are
491491+ # available in the errors.po file (as we use the "errors" domain).
492492+ if count = opts[:count] do
493493+ Gettext.dngettext(AnnotAtWeb.Gettext, "errors", msg, msg, count, opts)
494494+ else
495495+ Gettext.dgettext(AnnotAtWeb.Gettext, "errors", msg, opts)
496496+ end
497497+ end
498498+499499+ @doc """
500500+ Translates the errors for a field from a keyword list of errors.
501501+ """
502502+ def translate_errors(errors, field) when is_list(errors) do
503503+ for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
504504+ end
505505+end
+154
lib/annot_at_web/components/layouts.ex
···11+defmodule AnnotAtWeb.Layouts do
22+ @moduledoc """
33+ This module holds layouts and related functionality
44+ used by your application.
55+ """
66+ use AnnotAtWeb, :html
77+88+ # Embed all files in layouts/* within this module.
99+ # The default root.html.heex file contains the HTML
1010+ # skeleton of your application, namely HTML headers
1111+ # and other static content.
1212+ embed_templates "layouts/*"
1313+1414+ @doc """
1515+ Renders your app layout.
1616+1717+ This function is typically invoked from every template,
1818+ and it often contains your application menu, sidebar,
1919+ or similar.
2020+2121+ ## Examples
2222+2323+ <Layouts.app flash={@flash}>
2424+ <h1>Content</h1>
2525+ </Layouts.app>
2626+2727+ """
2828+ attr :flash, :map, required: true, doc: "the map of flash messages"
2929+3030+ attr :current_scope, :map,
3131+ default: nil,
3232+ doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)"
3333+3434+ slot :inner_block, required: true
3535+3636+ def app(assigns) do
3737+ ~H"""
3838+ <header class="navbar px-4 sm:px-6 lg:px-8">
3939+ <div class="flex-1">
4040+ <a href="/" class="flex-1 flex w-fit items-center gap-2">
4141+ <img src={~p"/images/logo.svg"} width="36" />
4242+ <span class="text-sm font-semibold">v{Application.spec(:phoenix, :vsn)}</span>
4343+ </a>
4444+ </div>
4545+ <div class="flex-none">
4646+ <ul class="flex flex-column px-1 space-x-4 items-center">
4747+ <li>
4848+ <a href="https://phoenixframework.org/" class="btn btn-ghost">Website</a>
4949+ </li>
5050+ <li>
5151+ <a href="https://github.com/phoenixframework/phoenix" class="btn btn-ghost">GitHub</a>
5252+ </li>
5353+ <li>
5454+ <.theme_toggle />
5555+ </li>
5656+ <li>
5757+ <a href="https://hexdocs.pm/phoenix/overview.html" class="btn btn-primary">
5858+ Get Started <span aria-hidden="true">→</span>
5959+ </a>
6060+ </li>
6161+ </ul>
6262+ </div>
6363+ </header>
6464+6565+ <main class="px-4 py-20 sm:px-6 lg:px-8">
6666+ <div class="mx-auto max-w-2xl space-y-4">
6767+ {render_slot(@inner_block)}
6868+ </div>
6969+ </main>
7070+7171+ <.flash_group flash={@flash} />
7272+ """
7373+ end
7474+7575+ @doc """
7676+ Shows the flash group with standard titles and content.
7777+7878+ ## Examples
7979+8080+ <.flash_group flash={@flash} />
8181+ """
8282+ attr :flash, :map, required: true, doc: "the map of flash messages"
8383+ attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
8484+8585+ def flash_group(assigns) do
8686+ ~H"""
8787+ <div id={@id} aria-live="polite">
8888+ <.flash kind={:info} flash={@flash} />
8989+ <.flash kind={:error} flash={@flash} />
9090+9191+ <.flash
9292+ id="client-error"
9393+ kind={:error}
9494+ title={gettext("We can't find the internet")}
9595+ phx-disconnected={show(".phx-client-error #client-error") |> JS.remove_attribute("hidden")}
9696+ phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})}
9797+ hidden
9898+ >
9999+ {gettext("Attempting to reconnect")}
100100+ <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
101101+ </.flash>
102102+103103+ <.flash
104104+ id="server-error"
105105+ kind={:error}
106106+ title={gettext("Something went wrong!")}
107107+ phx-disconnected={show(".phx-server-error #server-error") |> JS.remove_attribute("hidden")}
108108+ phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})}
109109+ hidden
110110+ >
111111+ {gettext("Attempting to reconnect")}
112112+ <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
113113+ </.flash>
114114+ </div>
115115+ """
116116+ end
117117+118118+ @doc """
119119+ Provides dark vs light theme toggle based on themes defined in app.css.
120120+121121+ See <head> in root.html.heex which applies the theme before page load.
122122+ """
123123+ def theme_toggle(assigns) do
124124+ ~H"""
125125+ <div class="card relative flex flex-row items-center border-2 border-base-300 bg-base-300 rounded-full">
126126+ <div class="absolute w-1/3 h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0 [[data-theme=light]_&]:left-1/3 [[data-theme=dark]_&]:left-2/3 transition-[left]" />
127127+128128+ <button
129129+ class="flex p-2 cursor-pointer w-1/3"
130130+ phx-click={JS.dispatch("phx:set-theme")}
131131+ data-phx-theme="system"
132132+ >
133133+ <.icon name="hero-computer-desktop-micro" class="size-4 opacity-75 hover:opacity-100" />
134134+ </button>
135135+136136+ <button
137137+ class="flex p-2 cursor-pointer w-1/3"
138138+ phx-click={JS.dispatch("phx:set-theme")}
139139+ data-phx-theme="light"
140140+ >
141141+ <.icon name="hero-sun-micro" class="size-4 opacity-75 hover:opacity-100" />
142142+ </button>
143143+144144+ <button
145145+ class="flex p-2 cursor-pointer w-1/3"
146146+ phx-click={JS.dispatch("phx:set-theme")}
147147+ data-phx-theme="dark"
148148+ >
149149+ <.icon name="hero-moon-micro" class="size-4 opacity-75 hover:opacity-100" />
150150+ </button>
151151+ </div>
152152+ """
153153+ end
154154+end
···11+defmodule AnnotAtWeb.ErrorHTML do
22+ @moduledoc """
33+ This module is invoked by your endpoint in case of errors on HTML requests.
44+55+ See config/config.exs.
66+ """
77+ use AnnotAtWeb, :html
88+99+ # If you want to customize your error pages,
1010+ # uncomment the embed_templates/1 call below
1111+ # and add pages to the error directory:
1212+ #
1313+ # * lib/annot_at_web/controllers/error_html/404.html.heex
1414+ # * lib/annot_at_web/controllers/error_html/500.html.heex
1515+ #
1616+ # embed_templates "error_html/*"
1717+1818+ # The default is to render a plain text page based on
1919+ # the template name. For example, "404.html" becomes
2020+ # "Not Found".
2121+ def render(template, _assigns) do
2222+ Phoenix.Controller.status_message_from_template(template)
2323+ end
2424+end
+21
lib/annot_at_web/controllers/error_json.ex
···11+defmodule AnnotAtWeb.ErrorJSON do
22+ @moduledoc """
33+ This module is invoked by your endpoint in case of errors on JSON requests.
44+55+ See config/config.exs.
66+ """
77+88+ # If you want to customize a particular status code,
99+ # you may add your own clauses, such as:
1010+ #
1111+ # def render("500.json", _assigns) do
1212+ # %{errors: %{detail: "Internal Server Error"}}
1313+ # end
1414+1515+ # By default, Phoenix returns the status message from
1616+ # the template name. For example, "404.json" becomes
1717+ # "Not Found".
1818+ def render(template, _assigns) do
1919+ %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
2020+ end
2121+end
+7
lib/annot_at_web/controllers/page_controller.ex
···11+defmodule AnnotAtWeb.PageController do
22+ use AnnotAtWeb, :controller
33+44+ def home(conn, _params) do
55+ render(conn, :home)
66+ end
77+end
+10
lib/annot_at_web/controllers/page_html.ex
···11+defmodule AnnotAtWeb.PageHTML do
22+ @moduledoc """
33+ This module contains pages rendered by PageController.
44+55+ See the `page_html` directory for all templates available.
66+ """
77+ use AnnotAtWeb, :html
88+99+ embed_templates "page_html/*"
1010+end
···11+defmodule AnnotAtWeb.Endpoint do
22+ use Phoenix.Endpoint, otp_app: :annot_at
33+44+ # The session will be stored in the cookie and signed,
55+ # this means its contents can be read but not tampered with.
66+ # Set :encryption_salt if you would also like to encrypt it.
77+ @session_options [
88+ store: :cookie,
99+ key: "_annot_at_key",
1010+ signing_salt: "VuDTfo9L",
1111+ same_site: "Lax"
1212+ ]
1313+1414+ socket "/live", Phoenix.LiveView.Socket,
1515+ websocket: [connect_info: [session: @session_options]],
1616+ longpoll: [connect_info: [session: @session_options]]
1717+1818+ # Serve at "/" the static files from "priv/static" directory.
1919+ #
2020+ # When code reloading is disabled (e.g., in production),
2121+ # the `gzip` option is enabled to serve compressed
2222+ # static files generated by running `phx.digest`.
2323+ plug Plug.Static,
2424+ at: "/",
2525+ from: :annot_at,
2626+ gzip: not code_reloading?,
2727+ only: AnnotAtWeb.static_paths(),
2828+ raise_on_missing_only: code_reloading?
2929+3030+ # Code reloading can be explicitly enabled under the
3131+ # :code_reloader configuration of your endpoint.
3232+ if code_reloading? do
3333+ socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
3434+ plug Phoenix.LiveReloader
3535+ plug Phoenix.CodeReloader
3636+ plug Phoenix.Ecto.CheckRepoStatus, otp_app: :annot_at
3737+ end
3838+3939+ plug Phoenix.LiveDashboard.RequestLogger,
4040+ param_key: "request_logger",
4141+ cookie_key: "request_logger"
4242+4343+ plug Plug.RequestId
4444+ plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
4545+4646+ plug Plug.Parsers,
4747+ parsers: [:urlencoded, :multipart, :json],
4848+ pass: ["*/*"],
4949+ json_decoder: Phoenix.json_library()
5050+5151+ plug Plug.MethodOverride
5252+ plug Plug.Head
5353+ plug Plug.Session, @session_options
5454+ plug AnnotAtWeb.Router
5555+end
+25
lib/annot_at_web/gettext.ex
···11+defmodule AnnotAtWeb.Gettext do
22+ @moduledoc """
33+ A module providing Internationalization with a gettext-based API.
44+55+ By using [Gettext](https://hexdocs.pm/gettext), your module compiles translations
66+ that you can use in your application. To use this Gettext backend module,
77+ call `use Gettext` and pass it as an option:
88+99+ use Gettext, backend: AnnotAtWeb.Gettext
1010+1111+ # Simple translation
1212+ gettext("Here is the string to translate")
1313+1414+ # Plural translation
1515+ ngettext("Here is the string to translate",
1616+ "Here are the strings to translate",
1717+ 3)
1818+1919+ # Domain-based translation
2020+ dgettext("errors", "Here is the error message to translate")
2121+2222+ See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
2323+ """
2424+ use Gettext.Backend, otp_app: :annot_at
2525+end
+44
lib/annot_at_web/router.ex
···11+defmodule AnnotAtWeb.Router do
22+ use AnnotAtWeb, :router
33+44+ pipeline :browser do
55+ plug :accepts, ["html"]
66+ plug :fetch_session
77+ plug :fetch_live_flash
88+ plug :put_root_layout, html: {AnnotAtWeb.Layouts, :root}
99+ plug :protect_from_forgery
1010+ plug :put_secure_browser_headers
1111+ end
1212+1313+ pipeline :api do
1414+ plug :accepts, ["json"]
1515+ end
1616+1717+ scope "/", AnnotAtWeb do
1818+ pipe_through :browser
1919+2020+ get "/", PageController, :home
2121+ end
2222+2323+ # Other scopes may use custom stacks.
2424+ # scope "/api", AnnotAtWeb do
2525+ # pipe_through :api
2626+ # end
2727+2828+ # Enable LiveDashboard and Swoosh mailbox preview in development
2929+ if Application.compile_env(:annot_at, :dev_routes) do
3030+ # If you want to use the LiveDashboard in production, you should put
3131+ # it behind authentication and allow only admins to access it.
3232+ # If your application does not have an admins-only section yet,
3333+ # you can use Plug.BasicAuth to set up some basic authentication
3434+ # as long as you are also using SSL (which you should anyway).
3535+ import Phoenix.LiveDashboard.Router
3636+3737+ scope "/dev" do
3838+ pipe_through :browser
3939+4040+ live_dashboard "/dashboard", metrics: AnnotAtWeb.Telemetry
4141+ forward "/mailbox", Plug.Swoosh.MailboxPreview
4242+ end
4343+ end
4444+end
+93
lib/annot_at_web/telemetry.ex
···11+defmodule AnnotAtWeb.Telemetry do
22+ use Supervisor
33+ import Telemetry.Metrics
44+55+ def start_link(arg) do
66+ Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
77+ end
88+99+ @impl true
1010+ def init(_arg) do
1111+ children = [
1212+ # Telemetry poller will execute the given period measurements
1313+ # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
1414+ {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
1515+ # Add reporters as children of your supervision tree.
1616+ # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
1717+ ]
1818+1919+ Supervisor.init(children, strategy: :one_for_one)
2020+ end
2121+2222+ def metrics do
2323+ [
2424+ # Phoenix Metrics
2525+ summary("phoenix.endpoint.start.system_time",
2626+ unit: {:native, :millisecond}
2727+ ),
2828+ summary("phoenix.endpoint.stop.duration",
2929+ unit: {:native, :millisecond}
3030+ ),
3131+ summary("phoenix.router_dispatch.start.system_time",
3232+ tags: [:route],
3333+ unit: {:native, :millisecond}
3434+ ),
3535+ summary("phoenix.router_dispatch.exception.duration",
3636+ tags: [:route],
3737+ unit: {:native, :millisecond}
3838+ ),
3939+ summary("phoenix.router_dispatch.stop.duration",
4040+ tags: [:route],
4141+ unit: {:native, :millisecond}
4242+ ),
4343+ summary("phoenix.socket_connected.duration",
4444+ unit: {:native, :millisecond}
4545+ ),
4646+ sum("phoenix.socket_drain.count"),
4747+ summary("phoenix.channel_joined.duration",
4848+ unit: {:native, :millisecond}
4949+ ),
5050+ summary("phoenix.channel_handled_in.duration",
5151+ tags: [:event],
5252+ unit: {:native, :millisecond}
5353+ ),
5454+5555+ # Database Metrics
5656+ summary("annot_at.repo.query.total_time",
5757+ unit: {:native, :millisecond},
5858+ description: "The sum of the other measurements"
5959+ ),
6060+ summary("annot_at.repo.query.decode_time",
6161+ unit: {:native, :millisecond},
6262+ description: "The time spent decoding the data received from the database"
6363+ ),
6464+ summary("annot_at.repo.query.query_time",
6565+ unit: {:native, :millisecond},
6666+ description: "The time spent executing the query"
6767+ ),
6868+ summary("annot_at.repo.query.queue_time",
6969+ unit: {:native, :millisecond},
7070+ description: "The time spent waiting for a database connection"
7171+ ),
7272+ summary("annot_at.repo.query.idle_time",
7373+ unit: {:native, :millisecond},
7474+ description:
7575+ "The time the connection spent waiting before being checked out for the query"
7676+ ),
7777+7878+ # VM Metrics
7979+ summary("vm.memory.total", unit: {:byte, :kilobyte}),
8080+ summary("vm.total_run_queue_lengths.total"),
8181+ summary("vm.total_run_queue_lengths.cpu"),
8282+ summary("vm.total_run_queue_lengths.io")
8383+ ]
8484+ end
8585+8686+ defp periodic_measurements do
8787+ [
8888+ # A module, function and arguments to be invoked periodically.
8989+ # This function must call :telemetry.execute/3 and a metric must be added above.
9090+ # {AnnotAtWeb, :count_users, []}
9191+ ]
9292+ end
9393+end
+94
mix.exs
···11+defmodule AnnotAt.MixProject do
22+ use Mix.Project
33+44+ def project do
55+ [
66+ app: :annot_at,
77+ version: "0.1.0",
88+ elixir: "~> 1.15",
99+ elixirc_paths: elixirc_paths(Mix.env()),
1010+ start_permanent: Mix.env() == :prod,
1111+ aliases: aliases(),
1212+ deps: deps(),
1313+ compilers: [:phoenix_live_view] ++ Mix.compilers(),
1414+ listeners: [Phoenix.CodeReloader]
1515+ ]
1616+ end
1717+1818+ # Configuration for the OTP application.
1919+ #
2020+ # Type `mix help compile.app` for more information.
2121+ def application do
2222+ [
2323+ mod: {AnnotAt.Application, []},
2424+ extra_applications: [:logger, :runtime_tools]
2525+ ]
2626+ end
2727+2828+ def cli do
2929+ [
3030+ preferred_envs: [precommit: :test]
3131+ ]
3232+ end
3333+3434+ # Specifies which paths to compile per environment.
3535+ defp elixirc_paths(:test), do: ["lib", "test/support"]
3636+ defp elixirc_paths(_), do: ["lib"]
3737+3838+ # Specifies your project dependencies.
3939+ #
4040+ # Type `mix help deps` for examples and options.
4141+ defp deps do
4242+ [
4343+ {:phoenix, "~> 1.8.7"},
4444+ {:phoenix_ecto, "~> 4.5"},
4545+ {:ecto_sql, "~> 3.13"},
4646+ {:postgrex, ">= 0.0.0"},
4747+ {:phoenix_html, "~> 4.1"},
4848+ {:phoenix_live_reload, "~> 1.2", only: :dev},
4949+ {:phoenix_live_view, "~> 1.1.0"},
5050+ {:lazy_html, ">= 0.1.0", only: :test},
5151+ {:phoenix_live_dashboard, "~> 0.8.3"},
5252+ {:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
5353+ {:tailwind, "~> 0.3", runtime: Mix.env() == :dev},
5454+ {:heroicons,
5555+ github: "tailwindlabs/heroicons",
5656+ tag: "v2.2.0",
5757+ sparse: "optimized",
5858+ app: false,
5959+ compile: false,
6060+ depth: 1},
6161+ {:swoosh, "~> 1.16"},
6262+ {:req, "~> 0.5"},
6363+ {:telemetry_metrics, "~> 1.0"},
6464+ {:telemetry_poller, "~> 1.0"},
6565+ {:gettext, "~> 1.0"},
6666+ {:jason, "~> 1.2"},
6767+ {:dns_cluster, "~> 0.2.0"},
6868+ {:bandit, "~> 1.5"}
6969+ ]
7070+ end
7171+7272+ # Aliases are shortcuts or tasks specific to the current project.
7373+ # For example, to install project dependencies and perform other setup tasks, run:
7474+ #
7575+ # $ mix setup
7676+ #
7777+ # See the documentation for `Mix` for more info on aliases.
7878+ defp aliases do
7979+ [
8080+ setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
8181+ "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
8282+ "ecto.reset": ["ecto.drop", "ecto.setup"],
8383+ test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
8484+ "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
8585+ "assets.build": ["compile", "tailwind annot_at", "esbuild annot_at"],
8686+ "assets.deploy": [
8787+ "tailwind annot_at --minify",
8888+ "esbuild annot_at --minify",
8989+ "phx.digest"
9090+ ],
9191+ precommit: ["compile --warnings-as-errors", "deps.unlock --unused", "format", "test"]
9292+ ]
9393+ end
9494+end
···11+## `msgid`s in this file come from POT (.pot) files.
22+##
33+## Do not add, change, or remove `msgid`s manually here as
44+## they're tied to the ones in the corresponding POT file
55+## (with the same domain).
66+##
77+## Use `mix gettext.extract --merge` or `mix gettext.merge`
88+## to merge POT files into PO files.
99+msgid ""
1010+msgstr ""
1111+"Language: en\n"
1212+1313+## From Ecto.Changeset.cast/4
1414+msgid "can't be blank"
1515+msgstr ""
1616+1717+## From Ecto.Changeset.unique_constraint/3
1818+msgid "has already been taken"
1919+msgstr ""
2020+2121+## From Ecto.Changeset.put_change/3
2222+msgid "is invalid"
2323+msgstr ""
2424+2525+## From Ecto.Changeset.validate_acceptance/3
2626+msgid "must be accepted"
2727+msgstr ""
2828+2929+## From Ecto.Changeset.validate_format/3
3030+msgid "has invalid format"
3131+msgstr ""
3232+3333+## From Ecto.Changeset.validate_subset/3
3434+msgid "has an invalid entry"
3535+msgstr ""
3636+3737+## From Ecto.Changeset.validate_exclusion/3
3838+msgid "is reserved"
3939+msgstr ""
4040+4141+## From Ecto.Changeset.validate_confirmation/3
4242+msgid "does not match confirmation"
4343+msgstr ""
4444+4545+## From Ecto.Changeset.no_assoc_constraint/3
4646+msgid "is still associated with this entry"
4747+msgstr ""
4848+4949+msgid "are still associated with this entry"
5050+msgstr ""
5151+5252+## From Ecto.Changeset.validate_length/3
5353+msgid "should have %{count} item(s)"
5454+msgid_plural "should have %{count} item(s)"
5555+msgstr[0] ""
5656+msgstr[1] ""
5757+5858+msgid "should be %{count} character(s)"
5959+msgid_plural "should be %{count} character(s)"
6060+msgstr[0] ""
6161+msgstr[1] ""
6262+6363+msgid "should be %{count} byte(s)"
6464+msgid_plural "should be %{count} byte(s)"
6565+msgstr[0] ""
6666+msgstr[1] ""
6767+6868+msgid "should have at least %{count} item(s)"
6969+msgid_plural "should have at least %{count} item(s)"
7070+msgstr[0] ""
7171+msgstr[1] ""
7272+7373+msgid "should be at least %{count} character(s)"
7474+msgid_plural "should be at least %{count} character(s)"
7575+msgstr[0] ""
7676+msgstr[1] ""
7777+7878+msgid "should be at least %{count} byte(s)"
7979+msgid_plural "should be at least %{count} byte(s)"
8080+msgstr[0] ""
8181+msgstr[1] ""
8282+8383+msgid "should have at most %{count} item(s)"
8484+msgid_plural "should have at most %{count} item(s)"
8585+msgstr[0] ""
8686+msgstr[1] ""
8787+8888+msgid "should be at most %{count} character(s)"
8989+msgid_plural "should be at most %{count} character(s)"
9090+msgstr[0] ""
9191+msgstr[1] ""
9292+9393+msgid "should be at most %{count} byte(s)"
9494+msgid_plural "should be at most %{count} byte(s)"
9595+msgstr[0] ""
9696+msgstr[1] ""
9797+9898+## From Ecto.Changeset.validate_number/3
9999+msgid "must be less than %{number}"
100100+msgstr ""
101101+102102+msgid "must be greater than %{number}"
103103+msgstr ""
104104+105105+msgid "must be less than or equal to %{number}"
106106+msgstr ""
107107+108108+msgid "must be greater than or equal to %{number}"
109109+msgstr ""
110110+111111+msgid "must be equal to %{number}"
112112+msgstr ""
+109
priv/gettext/errors.pot
···11+## This is a PO Template file.
22+##
33+## `msgid`s here are often extracted from source code.
44+## Add new translations manually only if they're dynamic
55+## translations that can't be statically extracted.
66+##
77+## Run `mix gettext.extract` to bring this file up to
88+## date. Leave `msgstr`s empty as changing them here has no
99+## effect: edit them in PO (`.po`) files instead.
1010+## From Ecto.Changeset.cast/4
1111+msgid "can't be blank"
1212+msgstr ""
1313+1414+## From Ecto.Changeset.unique_constraint/3
1515+msgid "has already been taken"
1616+msgstr ""
1717+1818+## From Ecto.Changeset.put_change/3
1919+msgid "is invalid"
2020+msgstr ""
2121+2222+## From Ecto.Changeset.validate_acceptance/3
2323+msgid "must be accepted"
2424+msgstr ""
2525+2626+## From Ecto.Changeset.validate_format/3
2727+msgid "has invalid format"
2828+msgstr ""
2929+3030+## From Ecto.Changeset.validate_subset/3
3131+msgid "has an invalid entry"
3232+msgstr ""
3333+3434+## From Ecto.Changeset.validate_exclusion/3
3535+msgid "is reserved"
3636+msgstr ""
3737+3838+## From Ecto.Changeset.validate_confirmation/3
3939+msgid "does not match confirmation"
4040+msgstr ""
4141+4242+## From Ecto.Changeset.no_assoc_constraint/3
4343+msgid "is still associated with this entry"
4444+msgstr ""
4545+4646+msgid "are still associated with this entry"
4747+msgstr ""
4848+4949+## From Ecto.Changeset.validate_length/3
5050+msgid "should have %{count} item(s)"
5151+msgid_plural "should have %{count} item(s)"
5252+msgstr[0] ""
5353+msgstr[1] ""
5454+5555+msgid "should be %{count} character(s)"
5656+msgid_plural "should be %{count} character(s)"
5757+msgstr[0] ""
5858+msgstr[1] ""
5959+6060+msgid "should be %{count} byte(s)"
6161+msgid_plural "should be %{count} byte(s)"
6262+msgstr[0] ""
6363+msgstr[1] ""
6464+6565+msgid "should have at least %{count} item(s)"
6666+msgid_plural "should have at least %{count} item(s)"
6767+msgstr[0] ""
6868+msgstr[1] ""
6969+7070+msgid "should be at least %{count} character(s)"
7171+msgid_plural "should be at least %{count} character(s)"
7272+msgstr[0] ""
7373+msgstr[1] ""
7474+7575+msgid "should be at least %{count} byte(s)"
7676+msgid_plural "should be at least %{count} byte(s)"
7777+msgstr[0] ""
7878+msgstr[1] ""
7979+8080+msgid "should have at most %{count} item(s)"
8181+msgid_plural "should have at most %{count} item(s)"
8282+msgstr[0] ""
8383+msgstr[1] ""
8484+8585+msgid "should be at most %{count} character(s)"
8686+msgid_plural "should be at most %{count} character(s)"
8787+msgstr[0] ""
8888+msgstr[1] ""
8989+9090+msgid "should be at most %{count} byte(s)"
9191+msgid_plural "should be at most %{count} byte(s)"
9292+msgstr[0] ""
9393+msgstr[1] ""
9494+9595+## From Ecto.Changeset.validate_number/3
9696+msgid "must be less than %{number}"
9797+msgstr ""
9898+9999+msgid "must be greater than %{number}"
100100+msgstr ""
101101+102102+msgid "must be less than or equal to %{number}"
103103+msgstr ""
104104+105105+msgid "must be greater than or equal to %{number}"
106106+msgstr ""
107107+108108+msgid "must be equal to %{number}"
109109+msgstr ""
···11+# Script for populating the database. You can run it as:
22+#
33+# mix run priv/repo/seeds.exs
44+#
55+# Inside the script, you can read and write to any of your
66+# repositories directly:
77+#
88+# AnnotAt.Repo.insert!(%AnnotAt.SomeSchema{})
99+#
1010+# We recommend using the bang functions (`insert!`, `update!`
1111+# and so on) as they will fail if something goes wrong.
···11+# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
22+#
33+# To ban all spiders from the entire site uncomment the next two lines:
44+# User-agent: *
55+# Disallow: /
+14
test/annot_at_web/controllers/error_html_test.exs
···11+defmodule AnnotAtWeb.ErrorHTMLTest do
22+ use AnnotAtWeb.ConnCase, async: true
33+44+ # Bring render_to_string/4 for testing custom views
55+ import Phoenix.Template, only: [render_to_string: 4]
66+77+ test "renders 404.html" do
88+ assert render_to_string(AnnotAtWeb.ErrorHTML, "404", "html", []) == "Not Found"
99+ end
1010+1111+ test "renders 500.html" do
1212+ assert render_to_string(AnnotAtWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
1313+ end
1414+end
+12
test/annot_at_web/controllers/error_json_test.exs
···11+defmodule AnnotAtWeb.ErrorJSONTest do
22+ use AnnotAtWeb.ConnCase, async: true
33+44+ test "renders 404" do
55+ assert AnnotAtWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
66+ end
77+88+ test "renders 500" do
99+ assert AnnotAtWeb.ErrorJSON.render("500.json", %{}) ==
1010+ %{errors: %{detail: "Internal Server Error"}}
1111+ end
1212+end
···11+defmodule AnnotAtWeb.PageControllerTest do
22+ use AnnotAtWeb.ConnCase
33+44+ test "GET /", %{conn: conn} do
55+ conn = get(conn, ~p"/")
66+ assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
77+ end
88+end
+38
test/support/conn_case.ex
···11+defmodule AnnotAtWeb.ConnCase do
22+ @moduledoc """
33+ This module defines the test case to be used by
44+ tests that require setting up a connection.
55+66+ Such tests rely on `Phoenix.ConnTest` and also
77+ import other functionality to make it easier
88+ to build common data structures and query the data layer.
99+1010+ Finally, if the test case interacts with the database,
1111+ we enable the SQL sandbox, so changes done to the database
1212+ are reverted at the end of every test. If you are using
1313+ PostgreSQL, you can even run database tests asynchronously
1414+ by setting `use AnnotAtWeb.ConnCase, async: true`, although
1515+ this option is not recommended for other databases.
1616+ """
1717+1818+ use ExUnit.CaseTemplate
1919+2020+ using do
2121+ quote do
2222+ # The default endpoint for testing
2323+ @endpoint AnnotAtWeb.Endpoint
2424+2525+ use AnnotAtWeb, :verified_routes
2626+2727+ # Import conveniences for testing with connections
2828+ import Plug.Conn
2929+ import Phoenix.ConnTest
3030+ import AnnotAtWeb.ConnCase
3131+ end
3232+ end
3333+3434+ setup tags do
3535+ AnnotAt.DataCase.setup_sandbox(tags)
3636+ {:ok, conn: Phoenix.ConnTest.build_conn()}
3737+ end
3838+end
+58
test/support/data_case.ex
···11+defmodule AnnotAt.DataCase do
22+ @moduledoc """
33+ This module defines the setup for tests requiring
44+ access to the application's data layer.
55+66+ You may define functions here to be used as helpers in
77+ your tests.
88+99+ Finally, if the test case interacts with the database,
1010+ we enable the SQL sandbox, so changes done to the database
1111+ are reverted at the end of every test. If you are using
1212+ PostgreSQL, you can even run database tests asynchronously
1313+ by setting `use AnnotAt.DataCase, async: true`, although
1414+ this option is not recommended for other databases.
1515+ """
1616+1717+ use ExUnit.CaseTemplate
1818+1919+ using do
2020+ quote do
2121+ alias AnnotAt.Repo
2222+2323+ import Ecto
2424+ import Ecto.Changeset
2525+ import Ecto.Query
2626+ import AnnotAt.DataCase
2727+ end
2828+ end
2929+3030+ setup tags do
3131+ AnnotAt.DataCase.setup_sandbox(tags)
3232+ :ok
3333+ end
3434+3535+ @doc """
3636+ Sets up the sandbox based on the test tags.
3737+ """
3838+ def setup_sandbox(tags) do
3939+ pid = Ecto.Adapters.SQL.Sandbox.start_owner!(AnnotAt.Repo, shared: not tags[:async])
4040+ on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
4141+ end
4242+4343+ @doc """
4444+ A helper that transforms changeset errors into a map of messages.
4545+4646+ assert {:error, changeset} = Accounts.create_user(%{password: "short"})
4747+ assert "password is too short" in errors_on(changeset).password
4848+ assert %{password: ["password is too short"]} = errors_on(changeset)
4949+5050+ """
5151+ def errors_on(changeset) do
5252+ Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
5353+ Regex.replace(~r"%{(\w+)}", message, fn _, key ->
5454+ opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
5555+ end)
5656+ end)
5757+ end
5858+end