🔥 initial commit 🔥
This commit is contained in:
9
lib/mixer.ex
Normal file
9
lib/mixer.ex
Normal file
@@ -0,0 +1,9 @@
|
||||
defmodule Mixer do
|
||||
@moduledoc """
|
||||
Mixer keeps the contexts that define your domain
|
||||
and business logic.
|
||||
|
||||
Contexts are also responsible for managing your data, regardless
|
||||
if it comes from the database, an external API or others.
|
||||
"""
|
||||
end
|
||||
13
lib/mixer/accounts.ex
Normal file
13
lib/mixer/accounts.ex
Normal file
@@ -0,0 +1,13 @@
|
||||
defmodule Mixer.Accounts do
|
||||
use Ash.Domain, otp_app: :mixer, extensions: [AshAdmin.Domain]
|
||||
|
||||
admin do
|
||||
show? true
|
||||
end
|
||||
|
||||
resources do
|
||||
resource Mixer.Accounts.Token
|
||||
resource Mixer.Accounts.User
|
||||
resource Mixer.Accounts.ApiKey
|
||||
end
|
||||
end
|
||||
55
lib/mixer/accounts/api_key.ex
Normal file
55
lib/mixer/accounts/api_key.ex
Normal file
@@ -0,0 +1,55 @@
|
||||
defmodule Mixer.Accounts.ApiKey do
|
||||
use Ash.Resource,
|
||||
otp_app: :mixer,
|
||||
domain: Mixer.Accounts,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer]
|
||||
|
||||
postgres do
|
||||
table "api_keys"
|
||||
repo Mixer.Repo
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:read, :destroy]
|
||||
|
||||
create :create do
|
||||
primary? true
|
||||
accept [:user_id, :expires_at]
|
||||
|
||||
change {AshAuthentication.Strategy.ApiKey.GenerateApiKey,
|
||||
prefix: :mixer, hash: :api_key_hash}
|
||||
end
|
||||
end
|
||||
|
||||
policies do
|
||||
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
|
||||
authorize_if always()
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
attribute :api_key_hash, :binary do
|
||||
allow_nil? false
|
||||
sensitive? true
|
||||
end
|
||||
|
||||
attribute :expires_at, :utc_datetime_usec do
|
||||
allow_nil? false
|
||||
end
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :user, Mixer.Accounts.User
|
||||
end
|
||||
|
||||
calculations do
|
||||
calculate :valid, :boolean, expr(expires_at > now())
|
||||
end
|
||||
|
||||
identities do
|
||||
identity :unique_api_key, [:api_key_hash]
|
||||
end
|
||||
end
|
||||
114
lib/mixer/accounts/token.ex
Normal file
114
lib/mixer/accounts/token.ex
Normal file
@@ -0,0 +1,114 @@
|
||||
defmodule Mixer.Accounts.Token do
|
||||
use Ash.Resource,
|
||||
otp_app: :mixer,
|
||||
domain: Mixer.Accounts,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer],
|
||||
extensions: [AshAuthentication.TokenResource]
|
||||
|
||||
postgres do
|
||||
table "tokens"
|
||||
repo Mixer.Repo
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:read]
|
||||
|
||||
read :expired do
|
||||
description "Look up all expired tokens."
|
||||
filter expr(expires_at < now())
|
||||
end
|
||||
|
||||
read :get_token do
|
||||
description "Look up a token by JTI or token, and an optional purpose."
|
||||
get? true
|
||||
argument :token, :string, sensitive?: true
|
||||
argument :jti, :string, sensitive?: true
|
||||
argument :purpose, :string, sensitive?: false
|
||||
|
||||
prepare AshAuthentication.TokenResource.GetTokenPreparation
|
||||
end
|
||||
|
||||
action :revoked?, :boolean do
|
||||
description "Returns true if a revocation token is found for the provided token"
|
||||
argument :token, :string, sensitive?: true
|
||||
argument :jti, :string, sensitive?: true
|
||||
|
||||
run AshAuthentication.TokenResource.IsRevoked
|
||||
end
|
||||
|
||||
create :revoke_token do
|
||||
description "Revoke a token. Creates a revocation token corresponding to the provided token."
|
||||
accept [:extra_data]
|
||||
argument :token, :string, allow_nil?: false, sensitive?: true
|
||||
|
||||
change AshAuthentication.TokenResource.RevokeTokenChange
|
||||
end
|
||||
|
||||
create :revoke_jti do
|
||||
description "Revoke a token by JTI. Creates a revocation token corresponding to the provided jti."
|
||||
accept [:extra_data]
|
||||
argument :subject, :string, allow_nil?: false, sensitive?: true
|
||||
argument :jti, :string, allow_nil?: false, sensitive?: true
|
||||
|
||||
change AshAuthentication.TokenResource.RevokeJtiChange
|
||||
end
|
||||
|
||||
create :store_token do
|
||||
description "Stores a token used for the provided purpose."
|
||||
accept [:extra_data, :purpose]
|
||||
argument :token, :string, allow_nil?: false, sensitive?: true
|
||||
change AshAuthentication.TokenResource.StoreTokenChange
|
||||
end
|
||||
|
||||
destroy :expunge_expired do
|
||||
description "Deletes expired tokens."
|
||||
change filter(expr(expires_at < now()))
|
||||
end
|
||||
|
||||
update :revoke_all_stored_for_subject do
|
||||
description "Revokes all stored tokens for a specific subject."
|
||||
accept [:extra_data]
|
||||
argument :subject, :string, allow_nil?: false, sensitive?: true
|
||||
change AshAuthentication.TokenResource.RevokeAllStoredForSubjectChange
|
||||
end
|
||||
end
|
||||
|
||||
policies do
|
||||
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
|
||||
description "AshAuthentication can interact with the token resource"
|
||||
authorize_if always()
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
attribute :jti, :string do
|
||||
primary_key? true
|
||||
public? true
|
||||
allow_nil? false
|
||||
sensitive? true
|
||||
end
|
||||
|
||||
attribute :subject, :string do
|
||||
allow_nil? false
|
||||
public? true
|
||||
end
|
||||
|
||||
attribute :expires_at, :utc_datetime do
|
||||
allow_nil? false
|
||||
public? true
|
||||
end
|
||||
|
||||
attribute :purpose, :string do
|
||||
allow_nil? false
|
||||
public? true
|
||||
end
|
||||
|
||||
attribute :extra_data, :map do
|
||||
public? true
|
||||
end
|
||||
|
||||
create_timestamp :created_at
|
||||
update_timestamp :updated_at
|
||||
end
|
||||
end
|
||||
311
lib/mixer/accounts/user.ex
Normal file
311
lib/mixer/accounts/user.ex
Normal file
@@ -0,0 +1,311 @@
|
||||
defmodule Mixer.Accounts.User do
|
||||
use Ash.Resource,
|
||||
otp_app: :mixer,
|
||||
domain: Mixer.Accounts,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
authorizers: [Ash.Policy.Authorizer],
|
||||
extensions: [AshAuthentication]
|
||||
|
||||
authentication do
|
||||
add_ons do
|
||||
log_out_everywhere do
|
||||
apply_on_password_change? true
|
||||
end
|
||||
|
||||
confirmation :confirm_new_user do
|
||||
monitor_fields [:email]
|
||||
confirm_on_create? true
|
||||
confirm_on_update? false
|
||||
require_interaction? true
|
||||
confirmed_at_field :confirmed_at
|
||||
auto_confirm_actions [:sign_in_with_magic_link, :reset_password_with_token]
|
||||
sender Mixer.Accounts.User.Senders.SendNewUserConfirmationEmail
|
||||
end
|
||||
end
|
||||
|
||||
tokens do
|
||||
enabled? true
|
||||
token_resource Mixer.Accounts.Token
|
||||
signing_secret Mixer.Secrets
|
||||
store_all_tokens? true
|
||||
require_token_presence_for_authentication? true
|
||||
end
|
||||
|
||||
strategies do
|
||||
password :password do
|
||||
identity_field :email
|
||||
hash_provider AshAuthentication.BcryptProvider
|
||||
|
||||
resettable do
|
||||
sender Mixer.Accounts.User.Senders.SendPasswordResetEmail
|
||||
# these configurations will be the default in a future release
|
||||
password_reset_action_name :reset_password_with_token
|
||||
request_password_reset_action_name :request_password_reset_token
|
||||
end
|
||||
end
|
||||
|
||||
remember_me :remember_me
|
||||
|
||||
magic_link do
|
||||
identity_field :email
|
||||
registration_enabled? true
|
||||
require_interaction? true
|
||||
|
||||
sender Mixer.Accounts.User.Senders.SendMagicLinkEmail
|
||||
end
|
||||
|
||||
api_key :api_key do
|
||||
api_key_relationship :valid_api_keys
|
||||
api_key_hash_attribute :api_key_hash
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
postgres do
|
||||
table "users"
|
||||
repo Mixer.Repo
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:read]
|
||||
|
||||
read :get_by_subject do
|
||||
description "Get a user by the subject claim in a JWT"
|
||||
argument :subject, :string, allow_nil?: false
|
||||
get? true
|
||||
prepare AshAuthentication.Preparations.FilterBySubject
|
||||
end
|
||||
|
||||
update :change_password do
|
||||
# Use this action to allow users to change their password by providing
|
||||
# their current password and a new password.
|
||||
|
||||
require_atomic? false
|
||||
accept []
|
||||
argument :current_password, :string, sensitive?: true, allow_nil?: false
|
||||
|
||||
argument :password, :string,
|
||||
sensitive?: true,
|
||||
allow_nil?: false,
|
||||
constraints: [min_length: 8]
|
||||
|
||||
argument :password_confirmation, :string, sensitive?: true, allow_nil?: false
|
||||
|
||||
validate confirm(:password, :password_confirmation)
|
||||
|
||||
validate {AshAuthentication.Strategy.Password.PasswordValidation,
|
||||
strategy_name: :password, password_argument: :current_password}
|
||||
|
||||
change {AshAuthentication.Strategy.Password.HashPasswordChange, strategy_name: :password}
|
||||
end
|
||||
|
||||
read :sign_in_with_password do
|
||||
description "Attempt to sign in using a email and password."
|
||||
get? true
|
||||
|
||||
argument :email, :ci_string do
|
||||
description "The email to use for retrieving the user."
|
||||
allow_nil? false
|
||||
end
|
||||
|
||||
argument :password, :string do
|
||||
description "The password to check for the matching user."
|
||||
allow_nil? false
|
||||
sensitive? true
|
||||
end
|
||||
|
||||
# validates the provided email and password and generates a token
|
||||
prepare AshAuthentication.Strategy.Password.SignInPreparation
|
||||
|
||||
metadata :token, :string do
|
||||
description "A JWT that can be used to authenticate the user."
|
||||
allow_nil? false
|
||||
end
|
||||
end
|
||||
|
||||
read :sign_in_with_token do
|
||||
# In the generated sign in components, we validate the
|
||||
# email and password directly in the LiveView
|
||||
# and generate a short-lived token that can be used to sign in over
|
||||
# a standard controller action, exchanging it for a standard token.
|
||||
# This action performs that exchange. If you do not use the generated
|
||||
# liveviews, you may remove this action, and set
|
||||
# `sign_in_tokens_enabled? false` in the password strategy.
|
||||
|
||||
description "Attempt to sign in using a short-lived sign in token."
|
||||
get? true
|
||||
|
||||
argument :token, :string do
|
||||
description "The short-lived sign in token."
|
||||
allow_nil? false
|
||||
sensitive? true
|
||||
end
|
||||
|
||||
# validates the provided sign in token and generates a token
|
||||
prepare AshAuthentication.Strategy.Password.SignInWithTokenPreparation
|
||||
|
||||
metadata :token, :string do
|
||||
description "A JWT that can be used to authenticate the user."
|
||||
allow_nil? false
|
||||
end
|
||||
end
|
||||
|
||||
create :register_with_password do
|
||||
description "Register a new user with a email and password."
|
||||
|
||||
argument :email, :ci_string do
|
||||
allow_nil? false
|
||||
end
|
||||
|
||||
argument :password, :string do
|
||||
description "The proposed password for the user, in plain text."
|
||||
allow_nil? false
|
||||
constraints min_length: 8
|
||||
sensitive? true
|
||||
end
|
||||
|
||||
argument :password_confirmation, :string do
|
||||
description "The proposed password for the user (again), in plain text."
|
||||
allow_nil? false
|
||||
sensitive? true
|
||||
end
|
||||
|
||||
# Sets the email from the argument
|
||||
change set_attribute(:email, arg(:email))
|
||||
|
||||
# Hashes the provided password
|
||||
change AshAuthentication.Strategy.Password.HashPasswordChange
|
||||
|
||||
# Generates an authentication token for the user
|
||||
change AshAuthentication.GenerateTokenChange
|
||||
|
||||
# validates that the password matches the confirmation
|
||||
validate AshAuthentication.Strategy.Password.PasswordConfirmationValidation
|
||||
|
||||
metadata :token, :string do
|
||||
description "A JWT that can be used to authenticate the user."
|
||||
allow_nil? false
|
||||
end
|
||||
end
|
||||
|
||||
action :request_password_reset_token do
|
||||
description "Send password reset instructions to a user if they exist."
|
||||
|
||||
argument :email, :ci_string do
|
||||
allow_nil? false
|
||||
end
|
||||
|
||||
# creates a reset token and invokes the relevant senders
|
||||
run {AshAuthentication.Strategy.Password.RequestPasswordReset, action: :get_by_email}
|
||||
end
|
||||
|
||||
read :get_by_email do
|
||||
description "Looks up a user by their email"
|
||||
get_by :email
|
||||
end
|
||||
|
||||
update :reset_password_with_token do
|
||||
argument :reset_token, :string do
|
||||
allow_nil? false
|
||||
sensitive? true
|
||||
end
|
||||
|
||||
argument :password, :string do
|
||||
description "The proposed password for the user, in plain text."
|
||||
allow_nil? false
|
||||
constraints min_length: 8
|
||||
sensitive? true
|
||||
end
|
||||
|
||||
argument :password_confirmation, :string do
|
||||
description "The proposed password for the user (again), in plain text."
|
||||
allow_nil? false
|
||||
sensitive? true
|
||||
end
|
||||
|
||||
# validates the provided reset token
|
||||
validate AshAuthentication.Strategy.Password.ResetTokenValidation
|
||||
|
||||
# validates that the password matches the confirmation
|
||||
validate AshAuthentication.Strategy.Password.PasswordConfirmationValidation
|
||||
|
||||
# Hashes the provided password
|
||||
change AshAuthentication.Strategy.Password.HashPasswordChange
|
||||
|
||||
# Generates an authentication token for the user
|
||||
change AshAuthentication.GenerateTokenChange
|
||||
end
|
||||
|
||||
create :sign_in_with_magic_link do
|
||||
description "Sign in or register a user with magic link."
|
||||
|
||||
argument :token, :string do
|
||||
description "The token from the magic link that was sent to the user"
|
||||
allow_nil? false
|
||||
end
|
||||
|
||||
argument :remember_me, :boolean do
|
||||
description "Whether to generate a remember me token"
|
||||
allow_nil? true
|
||||
end
|
||||
|
||||
upsert? true
|
||||
upsert_identity :unique_email
|
||||
upsert_fields [:email]
|
||||
|
||||
# Uses the information from the token to create or sign in the user
|
||||
change AshAuthentication.Strategy.MagicLink.SignInChange
|
||||
|
||||
change {AshAuthentication.Strategy.RememberMe.MaybeGenerateTokenChange,
|
||||
strategy_name: :remember_me}
|
||||
|
||||
metadata :token, :string do
|
||||
allow_nil? false
|
||||
end
|
||||
end
|
||||
|
||||
action :request_magic_link do
|
||||
argument :email, :ci_string do
|
||||
allow_nil? false
|
||||
end
|
||||
|
||||
run AshAuthentication.Strategy.MagicLink.Request
|
||||
end
|
||||
|
||||
read :sign_in_with_api_key do
|
||||
argument :api_key, :string, allow_nil?: false
|
||||
prepare AshAuthentication.Strategy.ApiKey.SignInPreparation
|
||||
end
|
||||
end
|
||||
|
||||
policies do
|
||||
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
|
||||
authorize_if always()
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
attribute :email, :ci_string do
|
||||
allow_nil? false
|
||||
public? true
|
||||
end
|
||||
|
||||
attribute :hashed_password, :string do
|
||||
sensitive? true
|
||||
end
|
||||
|
||||
attribute :confirmed_at, :utc_datetime_usec
|
||||
end
|
||||
|
||||
relationships do
|
||||
has_many :valid_api_keys, Mixer.Accounts.ApiKey do
|
||||
filter expr(valid)
|
||||
end
|
||||
end
|
||||
|
||||
identities do
|
||||
identity :unique_email, [:email]
|
||||
end
|
||||
end
|
||||
40
lib/mixer/accounts/user/senders/send_magic_link_email.ex
Normal file
40
lib/mixer/accounts/user/senders/send_magic_link_email.ex
Normal file
@@ -0,0 +1,40 @@
|
||||
defmodule Mixer.Accounts.User.Senders.SendMagicLinkEmail do
|
||||
@moduledoc """
|
||||
Sends a magic link email
|
||||
"""
|
||||
|
||||
use AshAuthentication.Sender
|
||||
use MixerWeb, :verified_routes
|
||||
|
||||
import Swoosh.Email
|
||||
alias Mixer.Mailer
|
||||
|
||||
@impl true
|
||||
def send(user_or_email, token, _) do
|
||||
# if you get a user, its for a user that already exists.
|
||||
# if you get an email, then the user does not yet exist.
|
||||
|
||||
email =
|
||||
case user_or_email do
|
||||
%{email: email} -> email
|
||||
email -> email
|
||||
end
|
||||
|
||||
new()
|
||||
# TODO: Replace with your email
|
||||
|> from({"noreply", "noreply@example.com"})
|
||||
|> to(to_string(email))
|
||||
|> subject("Your login link")
|
||||
|> html_body(body(token: token, email: email))
|
||||
|> Mailer.deliver!()
|
||||
end
|
||||
|
||||
defp body(params) do
|
||||
# NOTE: You may have to change this to match your magic link acceptance URL.
|
||||
|
||||
"""
|
||||
<p>Hello, #{params[:email]}! Click this link to sign in:</p>
|
||||
<p><a href="#{url(~p"/magic_link/#{params[:token]}")}">#{url(~p"/magic_link/#{params[:token]}")}</a></p>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,32 @@
|
||||
defmodule Mixer.Accounts.User.Senders.SendNewUserConfirmationEmail do
|
||||
@moduledoc """
|
||||
Sends an email for a new user to confirm their email address.
|
||||
"""
|
||||
|
||||
use AshAuthentication.Sender
|
||||
use MixerWeb, :verified_routes
|
||||
|
||||
import Swoosh.Email
|
||||
|
||||
alias Mixer.Mailer
|
||||
|
||||
@impl true
|
||||
def send(user, token, _) do
|
||||
new()
|
||||
# TODO: Replace with your email
|
||||
|> from({"noreply", "noreply@example.com"})
|
||||
|> to(to_string(user.email))
|
||||
|> subject("Confirm your email address")
|
||||
|> html_body(body(token: token))
|
||||
|> Mailer.deliver!()
|
||||
end
|
||||
|
||||
defp body(params) do
|
||||
url = url(~p"/confirm_new_user/#{params[:token]}")
|
||||
|
||||
"""
|
||||
<p>Click this link to confirm your email:</p>
|
||||
<p><a href="#{url}">#{url}</a></p>
|
||||
"""
|
||||
end
|
||||
end
|
||||
32
lib/mixer/accounts/user/senders/send_password_reset_email.ex
Normal file
32
lib/mixer/accounts/user/senders/send_password_reset_email.ex
Normal file
@@ -0,0 +1,32 @@
|
||||
defmodule Mixer.Accounts.User.Senders.SendPasswordResetEmail do
|
||||
@moduledoc """
|
||||
Sends a password reset email
|
||||
"""
|
||||
|
||||
use AshAuthentication.Sender
|
||||
use MixerWeb, :verified_routes
|
||||
|
||||
import Swoosh.Email
|
||||
|
||||
alias Mixer.Mailer
|
||||
|
||||
@impl true
|
||||
def send(user, token, _) do
|
||||
new()
|
||||
# TODO: Replace with your email
|
||||
|> from({"noreply", "noreply@example.com"})
|
||||
|> to(to_string(user.email))
|
||||
|> subject("Reset your password")
|
||||
|> html_body(body(token: token))
|
||||
|> Mailer.deliver!()
|
||||
end
|
||||
|
||||
defp body(params) do
|
||||
url = url(~p"/password-reset/#{params[:token]}")
|
||||
|
||||
"""
|
||||
<p>Click this link to reset your password:</p>
|
||||
<p><a href="#{url}">#{url}</a></p>
|
||||
"""
|
||||
end
|
||||
end
|
||||
37
lib/mixer/application.ex
Normal file
37
lib/mixer/application.ex
Normal file
@@ -0,0 +1,37 @@
|
||||
defmodule Mixer.Application do
|
||||
# See https://hexdocs.pm/elixir/Application.html
|
||||
# for more information on OTP Applications
|
||||
@moduledoc false
|
||||
|
||||
use Application
|
||||
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
children = [
|
||||
MixerWeb.Telemetry,
|
||||
Mixer.Repo,
|
||||
{DNSCluster, query: Application.get_env(:mixer, :dns_cluster_query) || :ignore},
|
||||
{Phoenix.PubSub, name: Mixer.PubSub},
|
||||
# Start a worker by calling: Mixer.Worker.start_link(arg)
|
||||
# {Mixer.Worker, arg},
|
||||
# Start to serve requests, typically the last entry
|
||||
MixerWeb.Endpoint,
|
||||
{Absinthe.Subscription, MixerWeb.Endpoint},
|
||||
AshGraphql.Subscription.Batcher,
|
||||
{AshAuthentication.Supervisor, [otp_app: :mixer]}
|
||||
]
|
||||
|
||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||
# for other strategies and supported options
|
||||
opts = [strategy: :one_for_one, name: Mixer.Supervisor]
|
||||
Supervisor.start_link(children, opts)
|
||||
end
|
||||
|
||||
# Tell Phoenix to update the endpoint configuration
|
||||
# whenever the application is updated.
|
||||
@impl true
|
||||
def config_change(changed, _new, removed) do
|
||||
MixerWeb.Endpoint.config_change(changed, removed)
|
||||
:ok
|
||||
end
|
||||
end
|
||||
3
lib/mixer/mailer.ex
Normal file
3
lib/mixer/mailer.ex
Normal file
@@ -0,0 +1,3 @@
|
||||
defmodule Mixer.Mailer do
|
||||
use Swoosh.Mailer, otp_app: :mixer
|
||||
end
|
||||
22
lib/mixer/repo.ex
Normal file
22
lib/mixer/repo.ex
Normal file
@@ -0,0 +1,22 @@
|
||||
defmodule Mixer.Repo do
|
||||
use AshPostgres.Repo,
|
||||
otp_app: :mixer
|
||||
|
||||
@impl true
|
||||
def installed_extensions do
|
||||
# Add extensions here, and the migration generator will install them.
|
||||
["ash-functions", "citext"]
|
||||
end
|
||||
|
||||
# Don't open unnecessary transactions
|
||||
# will default to `false` in 4.0
|
||||
@impl true
|
||||
def prefer_transaction? do
|
||||
false
|
||||
end
|
||||
|
||||
@impl true
|
||||
def min_pg_version do
|
||||
%Version{major: 18, minor: 3, patch: 0}
|
||||
end
|
||||
end
|
||||
12
lib/mixer/secrets.ex
Normal file
12
lib/mixer/secrets.ex
Normal file
@@ -0,0 +1,12 @@
|
||||
defmodule Mixer.Secrets do
|
||||
use AshAuthentication.Secret
|
||||
|
||||
def secret_for(
|
||||
[:authentication, :tokens, :signing_secret],
|
||||
Mixer.Accounts.User,
|
||||
_opts,
|
||||
_context
|
||||
) do
|
||||
Application.fetch_env(:mixer, :token_signing_secret)
|
||||
end
|
||||
end
|
||||
114
lib/mixer_web.ex
Normal file
114
lib/mixer_web.ex
Normal file
@@ -0,0 +1,114 @@
|
||||
defmodule MixerWeb do
|
||||
@moduledoc """
|
||||
The entrypoint for defining your web interface, such
|
||||
as controllers, components, channels, and so on.
|
||||
|
||||
This can be used in your application as:
|
||||
|
||||
use MixerWeb, :controller
|
||||
use MixerWeb, :html
|
||||
|
||||
The definitions below will be executed for every controller,
|
||||
component, etc, so keep them short and clean, focused
|
||||
on imports, uses and aliases.
|
||||
|
||||
Do NOT define functions inside the quoted expressions
|
||||
below. Instead, define additional modules and import
|
||||
those modules here.
|
||||
"""
|
||||
|
||||
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
|
||||
|
||||
def router do
|
||||
quote do
|
||||
use Phoenix.Router, helpers: false
|
||||
|
||||
# Import common connection and controller functions to use in pipelines
|
||||
import Plug.Conn
|
||||
import Phoenix.Controller
|
||||
import Phoenix.LiveView.Router
|
||||
end
|
||||
end
|
||||
|
||||
def channel do
|
||||
quote do
|
||||
use Phoenix.Channel
|
||||
end
|
||||
end
|
||||
|
||||
def controller do
|
||||
quote do
|
||||
use Phoenix.Controller, formats: [:html, :json]
|
||||
|
||||
use Gettext, backend: MixerWeb.Gettext
|
||||
|
||||
import Plug.Conn
|
||||
|
||||
unquote(verified_routes())
|
||||
end
|
||||
end
|
||||
|
||||
def live_view do
|
||||
quote do
|
||||
use Phoenix.LiveView
|
||||
|
||||
unquote(html_helpers())
|
||||
end
|
||||
end
|
||||
|
||||
def live_component do
|
||||
quote do
|
||||
use Phoenix.LiveComponent
|
||||
|
||||
unquote(html_helpers())
|
||||
end
|
||||
end
|
||||
|
||||
def html do
|
||||
quote do
|
||||
use Phoenix.Component
|
||||
|
||||
# Import convenience functions from controllers
|
||||
import Phoenix.Controller,
|
||||
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
|
||||
|
||||
# Include general helpers for rendering HTML
|
||||
unquote(html_helpers())
|
||||
end
|
||||
end
|
||||
|
||||
defp html_helpers do
|
||||
quote do
|
||||
# Translation
|
||||
use Gettext, backend: MixerWeb.Gettext
|
||||
|
||||
# HTML escaping functionality
|
||||
import Phoenix.HTML
|
||||
# Core UI components
|
||||
import MixerWeb.CoreComponents
|
||||
|
||||
# Common modules used in templates
|
||||
alias Phoenix.LiveView.JS
|
||||
alias MixerWeb.Layouts
|
||||
|
||||
# Routes generation with the ~p sigil
|
||||
unquote(verified_routes())
|
||||
end
|
||||
end
|
||||
|
||||
def verified_routes do
|
||||
quote do
|
||||
use Phoenix.VerifiedRoutes,
|
||||
endpoint: MixerWeb.Endpoint,
|
||||
router: MixerWeb.Router,
|
||||
statics: MixerWeb.static_paths()
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
When used, dispatch to the appropriate controller/live_view/etc.
|
||||
"""
|
||||
defmacro __using__(which) when is_atom(which) do
|
||||
apply(__MODULE__, which, [])
|
||||
end
|
||||
end
|
||||
5
lib/mixer_web/ash_json_api_router.ex
Normal file
5
lib/mixer_web/ash_json_api_router.ex
Normal file
@@ -0,0 +1,5 @@
|
||||
defmodule MixerWeb.AshJsonApiRouter do
|
||||
use AshJsonApi.Router,
|
||||
domains: [],
|
||||
open_api: "/open_api"
|
||||
end
|
||||
20
lib/mixer_web/auth_overrides.ex
Normal file
20
lib/mixer_web/auth_overrides.ex
Normal file
@@ -0,0 +1,20 @@
|
||||
defmodule MixerWeb.AuthOverrides do
|
||||
use AshAuthentication.Phoenix.Overrides
|
||||
|
||||
# configure your UI overrides here
|
||||
|
||||
# First argument to `override` is the component name you are overriding.
|
||||
# The body contains any number of configurations you wish to override
|
||||
# Below are some examples
|
||||
|
||||
# For a complete reference, see https://hexdocs.pm/ash_authentication_phoenix/ui-overrides.html
|
||||
|
||||
# override AshAuthentication.Phoenix.Components.Banner do
|
||||
# set :image_url, "https://media.giphy.com/media/g7GKcSzwQfugw/giphy.gif"
|
||||
# set :text_class, "bg-red-500"
|
||||
# end
|
||||
|
||||
# override AshAuthentication.Phoenix.Components.SignIn do
|
||||
# set :show_banner, false
|
||||
# end
|
||||
end
|
||||
498
lib/mixer_web/components/core_components.ex
Normal file
498
lib/mixer_web/components/core_components.ex
Normal file
@@ -0,0 +1,498 @@
|
||||
defmodule MixerWeb.CoreComponents do
|
||||
@moduledoc """
|
||||
Provides core UI components.
|
||||
|
||||
At first glance, this module may seem daunting, but its goal is to provide
|
||||
core building blocks for your application, such as tables, forms, and
|
||||
inputs. The components consist mostly of markup and are well-documented
|
||||
with doc strings and declarative assigns. You may customize and style
|
||||
them in any way you want, based on your application growth and needs.
|
||||
|
||||
The foundation for styling is Tailwind CSS, a utility-first CSS framework,
|
||||
augmented with daisyUI, a Tailwind CSS plugin that provides UI components
|
||||
and themes. Here are useful references:
|
||||
|
||||
* [daisyUI](https://daisyui.com/docs/intro/) - a good place to get
|
||||
started and see the available components.
|
||||
|
||||
* [Tailwind CSS](https://tailwindcss.com) - the foundational framework
|
||||
we build on. You will use it for layout, sizing, flexbox, grid, and
|
||||
spacing.
|
||||
|
||||
* [Heroicons](https://heroicons.com) - see `icon/1` for usage.
|
||||
|
||||
* [Phoenix.Component](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html) -
|
||||
the component system used by Phoenix. Some components, such as `<.link>`
|
||||
and `<.form>`, are defined there.
|
||||
|
||||
"""
|
||||
use Phoenix.Component
|
||||
use Gettext, backend: MixerWeb.Gettext
|
||||
|
||||
alias Phoenix.LiveView.JS
|
||||
|
||||
@doc """
|
||||
Renders flash notices.
|
||||
|
||||
## Examples
|
||||
|
||||
<.flash kind={:info} flash={@flash} />
|
||||
<.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash>
|
||||
"""
|
||||
attr :id, :string, doc: "the optional id of flash container"
|
||||
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
|
||||
attr :title, :string, default: nil
|
||||
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
|
||||
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
|
||||
|
||||
slot :inner_block, doc: "the optional inner block that renders the flash message"
|
||||
|
||||
def flash(assigns) do
|
||||
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
|
||||
id={@id}
|
||||
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
||||
role="alert"
|
||||
class="toast toast-top toast-end z-50"
|
||||
{@rest}
|
||||
>
|
||||
<div class={[
|
||||
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
|
||||
@kind == :info && "alert-info",
|
||||
@kind == :error && "alert-error"
|
||||
]}>
|
||||
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
|
||||
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
|
||||
<div>
|
||||
<p :if={@title} class="font-semibold">{@title}</p>
|
||||
<p>{msg}</p>
|
||||
</div>
|
||||
<div class="flex-1" />
|
||||
<button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
|
||||
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a button with navigation support.
|
||||
|
||||
## Examples
|
||||
|
||||
<.button>Send!</.button>
|
||||
<.button phx-click="go" variant="primary">Send!</.button>
|
||||
<.button navigate={~p"/"}>Home</.button>
|
||||
"""
|
||||
attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
|
||||
attr :class, :any
|
||||
attr :variant, :string, values: ~w(primary)
|
||||
slot :inner_block, required: true
|
||||
|
||||
def button(%{rest: rest} = assigns) do
|
||||
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
|
||||
|
||||
assigns =
|
||||
assign_new(assigns, :class, fn ->
|
||||
["btn", Map.fetch!(variants, assigns[:variant])]
|
||||
end)
|
||||
|
||||
if rest[:href] || rest[:navigate] || rest[:patch] do
|
||||
~H"""
|
||||
<.link class={@class} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</.link>
|
||||
"""
|
||||
else
|
||||
~H"""
|
||||
<button class={@class} {@rest}>
|
||||
{render_slot(@inner_block)}
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders an input with label and error messages.
|
||||
|
||||
A `Phoenix.HTML.FormField` may be passed as argument,
|
||||
which is used to retrieve the input name, id, and values.
|
||||
Otherwise all attributes may be passed explicitly.
|
||||
|
||||
## Types
|
||||
|
||||
This function accepts all HTML input types, considering that:
|
||||
|
||||
* You may also set `type="select"` to render a `<select>` tag
|
||||
|
||||
* `type="checkbox"` is used exclusively to render boolean values
|
||||
|
||||
* For live file uploads, see `Phoenix.Component.live_file_input/1`
|
||||
|
||||
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
|
||||
for more information. Unsupported types, such as radio, are best
|
||||
written directly in your templates.
|
||||
|
||||
## Examples
|
||||
|
||||
```heex
|
||||
<.input field={@form[:email]} type="email" />
|
||||
<.input name="my-input" errors={["oh no!"]} />
|
||||
```
|
||||
|
||||
## Select type
|
||||
|
||||
When using `type="select"`, you must pass the `options` and optionally
|
||||
a `value` to mark which option should be preselected.
|
||||
|
||||
```heex
|
||||
<.input field={@form[:user_type]} type="select" options={["Admin": "admin", "User": "user"]} />
|
||||
```
|
||||
|
||||
For more information on what kind of data can be passed to `options` see
|
||||
[`options_for_select`](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#options_for_select/2).
|
||||
"""
|
||||
attr :id, :any, default: nil
|
||||
attr :name, :any
|
||||
attr :label, :string, default: nil
|
||||
attr :value, :any
|
||||
|
||||
attr :type, :string,
|
||||
default: "text",
|
||||
values: ~w(checkbox color date datetime-local email file month number password
|
||||
search select tel text textarea time url week hidden)
|
||||
|
||||
attr :field, Phoenix.HTML.FormField,
|
||||
doc: "a form field struct retrieved from the form, for example: @form[:email]"
|
||||
|
||||
attr :errors, :list, default: []
|
||||
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
|
||||
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
|
||||
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
|
||||
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
|
||||
attr :class, :any, default: nil, doc: "the input class to use over defaults"
|
||||
attr :error_class, :any, default: nil, doc: "the input error class to use over defaults"
|
||||
|
||||
attr :rest, :global,
|
||||
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
|
||||
multiple pattern placeholder readonly required rows size step)
|
||||
|
||||
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
|
||||
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
|
||||
|
||||
assigns
|
||||
|> assign(field: nil, id: assigns.id || field.id)
|
||||
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|
||||
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|
||||
|> assign_new(:value, fn -> field.value end)
|
||||
|> input()
|
||||
end
|
||||
|
||||
def input(%{type: "hidden"} = assigns) do
|
||||
~H"""
|
||||
<input type="hidden" id={@id} name={@name} value={@value} {@rest} />
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "checkbox"} = assigns) do
|
||||
assigns =
|
||||
assign_new(assigns, :checked, fn ->
|
||||
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
|
||||
end)
|
||||
|
||||
~H"""
|
||||
<div class="fieldset mb-2">
|
||||
<label for={@id}>
|
||||
<input
|
||||
type="hidden"
|
||||
name={@name}
|
||||
value="false"
|
||||
disabled={@rest[:disabled]}
|
||||
form={@rest[:form]}
|
||||
/>
|
||||
<span class="label">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={@id}
|
||||
name={@name}
|
||||
value="true"
|
||||
checked={@checked}
|
||||
class={@class || "checkbox checkbox-sm"}
|
||||
{@rest}
|
||||
/>{@label}
|
||||
</span>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "select"} = assigns) do
|
||||
~H"""
|
||||
<div class="fieldset mb-2">
|
||||
<label for={@id}>
|
||||
<span :if={@label} class="label mb-1">{@label}</span>
|
||||
<select
|
||||
id={@id}
|
||||
name={@name}
|
||||
class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
|
||||
multiple={@multiple}
|
||||
{@rest}
|
||||
>
|
||||
<option :if={@prompt} value="">{@prompt}</option>
|
||||
{Phoenix.HTML.Form.options_for_select(@options, @value)}
|
||||
</select>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "textarea"} = assigns) do
|
||||
~H"""
|
||||
<div class="fieldset mb-2">
|
||||
<label for={@id}>
|
||||
<span :if={@label} class="label mb-1">{@label}</span>
|
||||
<textarea
|
||||
id={@id}
|
||||
name={@name}
|
||||
class={[
|
||||
@class || "w-full textarea",
|
||||
@errors != [] && (@error_class || "textarea-error")
|
||||
]}
|
||||
{@rest}
|
||||
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# All other inputs text, datetime-local, url, password, etc. are handled here...
|
||||
def input(assigns) do
|
||||
~H"""
|
||||
<div class="fieldset mb-2">
|
||||
<label for={@id}>
|
||||
<span :if={@label} class="label mb-1">{@label}</span>
|
||||
<input
|
||||
type={@type}
|
||||
name={@name}
|
||||
id={@id}
|
||||
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
||||
class={[
|
||||
@class || "w-full input",
|
||||
@errors != [] && (@error_class || "input-error")
|
||||
]}
|
||||
{@rest}
|
||||
/>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Helper used by inputs to generate form errors
|
||||
defp error(assigns) do
|
||||
~H"""
|
||||
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
|
||||
<.icon name="hero-exclamation-circle" class="size-5" />
|
||||
{render_slot(@inner_block)}
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a header with title.
|
||||
"""
|
||||
slot :inner_block, required: true
|
||||
slot :subtitle
|
||||
slot :actions
|
||||
|
||||
def header(assigns) do
|
||||
~H"""
|
||||
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold leading-8">
|
||||
{render_slot(@inner_block)}
|
||||
</h1>
|
||||
<p :if={@subtitle != []} class="text-sm text-base-content/70">
|
||||
{render_slot(@subtitle)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-none">{render_slot(@actions)}</div>
|
||||
</header>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a table with generic styling.
|
||||
|
||||
## Examples
|
||||
|
||||
<.table id="users" rows={@users}>
|
||||
<:col :let={user} label="id">{user.id}</:col>
|
||||
<:col :let={user} label="username">{user.username}</:col>
|
||||
</.table>
|
||||
"""
|
||||
attr :id, :string, required: true
|
||||
attr :rows, :list, required: true
|
||||
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
|
||||
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
|
||||
|
||||
attr :row_item, :any,
|
||||
default: &Function.identity/1,
|
||||
doc: "the function for mapping each row before calling the :col and :action slots"
|
||||
|
||||
slot :col, required: true do
|
||||
attr :label, :string
|
||||
end
|
||||
|
||||
slot :action, doc: "the slot for showing user actions in the last table column"
|
||||
|
||||
def table(assigns) do
|
||||
assigns =
|
||||
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
|
||||
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
|
||||
end
|
||||
|
||||
~H"""
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th :for={col <- @col}>{col[:label]}</th>
|
||||
<th :if={@action != []}>
|
||||
<span class="sr-only">{gettext("Actions")}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
|
||||
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
|
||||
<td
|
||||
:for={col <- @col}
|
||||
phx-click={@row_click && @row_click.(row)}
|
||||
class={@row_click && "hover:cursor-pointer"}
|
||||
>
|
||||
{render_slot(col, @row_item.(row))}
|
||||
</td>
|
||||
<td :if={@action != []} class="w-0 font-semibold">
|
||||
<div class="flex gap-4">
|
||||
<%= for action <- @action do %>
|
||||
{render_slot(action, @row_item.(row))}
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a data list.
|
||||
|
||||
## Examples
|
||||
|
||||
<.list>
|
||||
<:item title="Title">{@post.title}</:item>
|
||||
<:item title="Views">{@post.views}</:item>
|
||||
</.list>
|
||||
"""
|
||||
slot :item, required: true do
|
||||
attr :title, :string, required: true
|
||||
end
|
||||
|
||||
def list(assigns) do
|
||||
~H"""
|
||||
<ul class="list">
|
||||
<li :for={item <- @item} class="list-row">
|
||||
<div class="list-col-grow">
|
||||
<div class="font-bold">{item.title}</div>
|
||||
<div>{render_slot(item)}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a [Heroicon](https://heroicons.com).
|
||||
|
||||
Heroicons come in three styles – outline, solid, and mini.
|
||||
By default, the outline style is used, but solid and mini may
|
||||
be applied by using the `-solid` and `-mini` suffix.
|
||||
|
||||
You can customize the size and colors of the icons by setting
|
||||
width, height, and background color classes.
|
||||
|
||||
Icons are extracted from the `deps/heroicons` directory and bundled within
|
||||
your compiled app.css by the plugin in `assets/vendor/heroicons.js`.
|
||||
|
||||
## Examples
|
||||
|
||||
<.icon name="hero-x-mark" />
|
||||
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||
"""
|
||||
attr :name, :string, required: true
|
||||
attr :class, :any, default: "size-4"
|
||||
|
||||
def icon(%{name: "hero-" <> _} = assigns) do
|
||||
~H"""
|
||||
<span class={[@name, @class]} />
|
||||
"""
|
||||
end
|
||||
|
||||
## JS Commands
|
||||
|
||||
def show(js \\ %JS{}, selector) do
|
||||
JS.show(js,
|
||||
to: selector,
|
||||
time: 300,
|
||||
transition:
|
||||
{"transition-all ease-out duration-300",
|
||||
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
|
||||
"opacity-100 translate-y-0 sm:scale-100"}
|
||||
)
|
||||
end
|
||||
|
||||
def hide(js \\ %JS{}, selector) do
|
||||
JS.hide(js,
|
||||
to: selector,
|
||||
time: 200,
|
||||
transition:
|
||||
{"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
|
||||
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Translates an error message using gettext.
|
||||
"""
|
||||
def translate_error({msg, opts}) do
|
||||
# When using gettext, we typically pass the strings we want
|
||||
# to translate as a static argument:
|
||||
#
|
||||
# # Translate the number of files with plural rules
|
||||
# dngettext("errors", "1 file", "%{count} files", count)
|
||||
#
|
||||
# However the error messages in our forms and APIs are generated
|
||||
# dynamically, so we need to translate them by calling Gettext
|
||||
# with our gettext backend as first argument. Translations are
|
||||
# available in the errors.po file (as we use the "errors" domain).
|
||||
if count = opts[:count] do
|
||||
Gettext.dngettext(MixerWeb.Gettext, "errors", msg, msg, count, opts)
|
||||
else
|
||||
Gettext.dgettext(MixerWeb.Gettext, "errors", msg, opts)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Translates the errors for a field from a keyword list of errors.
|
||||
"""
|
||||
def translate_errors(errors, field) when is_list(errors) do
|
||||
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
|
||||
end
|
||||
end
|
||||
154
lib/mixer_web/components/layouts.ex
Normal file
154
lib/mixer_web/components/layouts.ex
Normal file
@@ -0,0 +1,154 @@
|
||||
defmodule MixerWeb.Layouts do
|
||||
@moduledoc """
|
||||
This module holds layouts and related functionality
|
||||
used by your application.
|
||||
"""
|
||||
use MixerWeb, :html
|
||||
|
||||
# Embed all files in layouts/* within this module.
|
||||
# The default root.html.heex file contains the HTML
|
||||
# skeleton of your application, namely HTML headers
|
||||
# and other static content.
|
||||
embed_templates "layouts/*"
|
||||
|
||||
@doc """
|
||||
Renders your app layout.
|
||||
|
||||
This function is typically invoked from every template,
|
||||
and it often contains your application menu, sidebar,
|
||||
or similar.
|
||||
|
||||
## Examples
|
||||
|
||||
<Layouts.app flash={@flash}>
|
||||
<h1>Content</h1>
|
||||
</Layouts.app>
|
||||
|
||||
"""
|
||||
attr :flash, :map, required: true, doc: "the map of flash messages"
|
||||
|
||||
attr :current_scope, :map,
|
||||
default: nil,
|
||||
doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)"
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
def app(assigns) do
|
||||
~H"""
|
||||
<header class="navbar px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex-1">
|
||||
<a href="/" class="flex-1 flex w-fit items-center gap-2">
|
||||
<img src={~p"/images/logo.svg"} width="36" />
|
||||
<span class="text-sm font-semibold">v{Application.spec(:phoenix, :vsn)}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<ul class="flex flex-column px-1 space-x-4 items-center">
|
||||
<li>
|
||||
<a href="https://phoenixframework.org/" class="btn btn-ghost">Website</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/phoenixframework/phoenix" class="btn btn-ghost">GitHub</a>
|
||||
</li>
|
||||
<li>
|
||||
<.theme_toggle />
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://hexdocs.pm/phoenix/overview.html" class="btn btn-primary">
|
||||
Get Started <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="px-4 py-20 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-2xl space-y-4">
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<.flash_group flash={@flash} />
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Shows the flash group with standard titles and content.
|
||||
|
||||
## Examples
|
||||
|
||||
<.flash_group flash={@flash} />
|
||||
"""
|
||||
attr :flash, :map, required: true, doc: "the map of flash messages"
|
||||
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
|
||||
|
||||
def flash_group(assigns) do
|
||||
~H"""
|
||||
<div id={@id} aria-live="polite">
|
||||
<.flash kind={:info} flash={@flash} />
|
||||
<.flash kind={:error} flash={@flash} />
|
||||
|
||||
<.flash
|
||||
id="client-error"
|
||||
kind={:error}
|
||||
title={gettext("We can't find the internet")}
|
||||
phx-disconnected={show(".phx-client-error #client-error") |> JS.remove_attribute("hidden")}
|
||||
phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})}
|
||||
hidden
|
||||
>
|
||||
{gettext("Attempting to reconnect")}
|
||||
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||
</.flash>
|
||||
|
||||
<.flash
|
||||
id="server-error"
|
||||
kind={:error}
|
||||
title={gettext("Something went wrong!")}
|
||||
phx-disconnected={show(".phx-server-error #server-error") |> JS.remove_attribute("hidden")}
|
||||
phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})}
|
||||
hidden
|
||||
>
|
||||
{gettext("Attempting to reconnect")}
|
||||
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
|
||||
</.flash>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Provides dark vs light theme toggle based on themes defined in app.css.
|
||||
|
||||
See <head> in root.html.heex which applies the theme before page load.
|
||||
"""
|
||||
def theme_toggle(assigns) do
|
||||
~H"""
|
||||
<div class="card relative flex flex-row items-center border-2 border-base-300 bg-base-300 rounded-full">
|
||||
<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]" />
|
||||
|
||||
<button
|
||||
class="flex p-2 cursor-pointer w-1/3"
|
||||
phx-click={JS.dispatch("phx:set-theme")}
|
||||
data-phx-theme="system"
|
||||
>
|
||||
<.icon name="hero-computer-desktop-micro" class="size-4 opacity-75 hover:opacity-100" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex p-2 cursor-pointer w-1/3"
|
||||
phx-click={JS.dispatch("phx:set-theme")}
|
||||
data-phx-theme="light"
|
||||
>
|
||||
<.icon name="hero-sun-micro" class="size-4 opacity-75 hover:opacity-100" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex p-2 cursor-pointer w-1/3"
|
||||
phx-click={JS.dispatch("phx:set-theme")}
|
||||
data-phx-theme="dark"
|
||||
>
|
||||
<.icon name="hero-moon-micro" class="size-4 opacity-75 hover:opacity-100" />
|
||||
</button>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
36
lib/mixer_web/components/layouts/root.html.heex
Normal file
36
lib/mixer_web/components/layouts/root.html.heex
Normal file
@@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="csrf-token" content={get_csrf_token()} />
|
||||
<.live_title default="Mixer" suffix=" · Phoenix Framework">
|
||||
{assigns[:page_title]}
|
||||
</.live_title>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
|
||||
<script defer phx-track-static type="module" src={~p"/assets/app.js"}>
|
||||
</script>
|
||||
<script>
|
||||
(() => {
|
||||
const setTheme = (theme) => {
|
||||
if (theme === "system") {
|
||||
localStorage.removeItem("phx:theme");
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
} else {
|
||||
localStorage.setItem("phx:theme", theme);
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}
|
||||
};
|
||||
if (!document.documentElement.hasAttribute("data-theme")) {
|
||||
setTheme(localStorage.getItem("phx:theme") || "system");
|
||||
}
|
||||
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
|
||||
|
||||
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
{@inner_content}
|
||||
</body>
|
||||
</html>
|
||||
19
lib/mixer_web/components/layouts/spa_root.html.heex
Normal file
19
lib/mixer_web/components/layouts/spa_root.html.heex
Normal file
@@ -0,0 +1,19 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2025 ash_typescript contributors <https://github.com/ash-project/ash_typescript/graphs/contributors>
|
||||
SPDX-License-Identifier: MIT
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="csrf-token" content={get_csrf_token()} />
|
||||
<.live_title default="AshTypescript">Page</.live_title>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
|
||||
</head>
|
||||
<body>
|
||||
{@inner_content}
|
||||
<script defer phx-track-static type="module" src={~p"/assets/index.js"}>
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
13
lib/mixer_web/controllers/ash_typescript_rpc_controller.ex
Normal file
13
lib/mixer_web/controllers/ash_typescript_rpc_controller.ex
Normal file
@@ -0,0 +1,13 @@
|
||||
defmodule MixerWeb.AshTypescriptRpcController do
|
||||
use MixerWeb, :controller
|
||||
|
||||
def run(conn, params) do
|
||||
result = AshTypescript.Rpc.run_action(:mixer, conn, params)
|
||||
json(conn, result)
|
||||
end
|
||||
|
||||
def validate(conn, params) do
|
||||
result = AshTypescript.Rpc.validate_action(:mixer, conn, params)
|
||||
json(conn, result)
|
||||
end
|
||||
end
|
||||
55
lib/mixer_web/controllers/auth_controller.ex
Normal file
55
lib/mixer_web/controllers/auth_controller.ex
Normal file
@@ -0,0 +1,55 @@
|
||||
defmodule MixerWeb.AuthController do
|
||||
use MixerWeb, :controller
|
||||
use AshAuthentication.Phoenix.Controller
|
||||
|
||||
def success(conn, activity, user, _token) do
|
||||
return_to = get_session(conn, :return_to) || ~p"/"
|
||||
|
||||
message =
|
||||
case activity do
|
||||
{:confirm_new_user, :confirm} -> "Your email address has now been confirmed"
|
||||
{:password, :reset} -> "Your password has successfully been reset"
|
||||
_ -> "You are now signed in"
|
||||
end
|
||||
|
||||
conn
|
||||
|> delete_session(:return_to)
|
||||
|> store_in_session(user)
|
||||
# If your resource has a different name, update the assign name here (i.e :current_admin)
|
||||
|> assign(:current_user, user)
|
||||
|> put_flash(:info, message)
|
||||
|> redirect(to: return_to)
|
||||
end
|
||||
|
||||
def failure(conn, activity, reason) do
|
||||
message =
|
||||
case {activity, reason} do
|
||||
{_,
|
||||
%AshAuthentication.Errors.AuthenticationFailed{
|
||||
caused_by: %Ash.Error.Forbidden{
|
||||
errors: [%AshAuthentication.Errors.CannotConfirmUnconfirmedUser{}]
|
||||
}
|
||||
}} ->
|
||||
"""
|
||||
You have already signed in another way, but have not confirmed your account.
|
||||
You can confirm your account using the link we sent to you, or by resetting your password.
|
||||
"""
|
||||
|
||||
_ ->
|
||||
"Incorrect email or password"
|
||||
end
|
||||
|
||||
conn
|
||||
|> put_flash(:error, message)
|
||||
|> redirect(to: ~p"/sign-in")
|
||||
end
|
||||
|
||||
def sign_out(conn, _params) do
|
||||
return_to = get_session(conn, :return_to) || ~p"/"
|
||||
|
||||
conn
|
||||
|> clear_session(:mixer)
|
||||
|> put_flash(:info, "You are now signed out")
|
||||
|> redirect(to: return_to)
|
||||
end
|
||||
end
|
||||
24
lib/mixer_web/controllers/error_html.ex
Normal file
24
lib/mixer_web/controllers/error_html.ex
Normal file
@@ -0,0 +1,24 @@
|
||||
defmodule MixerWeb.ErrorHTML do
|
||||
@moduledoc """
|
||||
This module is invoked by your endpoint in case of errors on HTML requests.
|
||||
|
||||
See config/config.exs.
|
||||
"""
|
||||
use MixerWeb, :html
|
||||
|
||||
# If you want to customize your error pages,
|
||||
# uncomment the embed_templates/1 call below
|
||||
# and add pages to the error directory:
|
||||
#
|
||||
# * lib/mixer_web/controllers/error_html/404.html.heex
|
||||
# * lib/mixer_web/controllers/error_html/500.html.heex
|
||||
#
|
||||
# embed_templates "error_html/*"
|
||||
|
||||
# The default is to render a plain text page based on
|
||||
# the template name. For example, "404.html" becomes
|
||||
# "Not Found".
|
||||
def render(template, _assigns) do
|
||||
Phoenix.Controller.status_message_from_template(template)
|
||||
end
|
||||
end
|
||||
21
lib/mixer_web/controllers/error_json.ex
Normal file
21
lib/mixer_web/controllers/error_json.ex
Normal file
@@ -0,0 +1,21 @@
|
||||
defmodule MixerWeb.ErrorJSON do
|
||||
@moduledoc """
|
||||
This module is invoked by your endpoint in case of errors on JSON requests.
|
||||
|
||||
See config/config.exs.
|
||||
"""
|
||||
|
||||
# If you want to customize a particular status code,
|
||||
# you may add your own clauses, such as:
|
||||
#
|
||||
# def render("500.json", _assigns) do
|
||||
# %{errors: %{detail: "Internal Server Error"}}
|
||||
# end
|
||||
|
||||
# By default, Phoenix returns the status message from
|
||||
# the template name. For example, "404.json" becomes
|
||||
# "Not Found".
|
||||
def render(template, _assigns) do
|
||||
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
|
||||
end
|
||||
end
|
||||
11
lib/mixer_web/controllers/page_controller.ex
Normal file
11
lib/mixer_web/controllers/page_controller.ex
Normal file
@@ -0,0 +1,11 @@
|
||||
defmodule MixerWeb.PageController do
|
||||
use MixerWeb, :controller
|
||||
|
||||
def home(conn, _params) do
|
||||
render(conn, :home)
|
||||
end
|
||||
|
||||
def index conn, _params do
|
||||
conn |> put_root_layout(html: {MixerWeb.Layouts, :spa_root}) |> render(:index)
|
||||
end
|
||||
end
|
||||
10
lib/mixer_web/controllers/page_html.ex
Normal file
10
lib/mixer_web/controllers/page_html.ex
Normal file
@@ -0,0 +1,10 @@
|
||||
defmodule MixerWeb.PageHTML do
|
||||
@moduledoc """
|
||||
This module contains pages rendered by PageController.
|
||||
|
||||
See the `page_html` directory for all templates available.
|
||||
"""
|
||||
use MixerWeb, :html
|
||||
|
||||
embed_templates "page_html/*"
|
||||
end
|
||||
202
lib/mixer_web/controllers/page_html/home.html.heex
Normal file
202
lib/mixer_web/controllers/page_html/home.html.heex
Normal file
@@ -0,0 +1,202 @@
|
||||
<Layouts.flash_group flash={@flash} />
|
||||
<div class="left-[40rem] fixed inset-y-0 right-0 z-0 hidden lg:block xl:left-[50rem]">
|
||||
<svg
|
||||
viewBox="0 0 1480 957"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
class="absolute inset-0 h-full w-full"
|
||||
preserveAspectRatio="xMinYMid slice"
|
||||
>
|
||||
<path fill="#EE7868" d="M0 0h1480v957H0z" />
|
||||
<path
|
||||
d="M137.542 466.27c-582.851-48.41-988.806-82.127-1608.412 658.2l67.39 810 3083.15-256.51L1535.94-49.622l-98.36 8.183C1269.29 281.468 734.115 515.799 146.47 467.012l-8.928-.742Z"
|
||||
fill="#FF9F92"
|
||||
/>
|
||||
<path
|
||||
d="M371.028 528.664C-169.369 304.988-545.754 149.198-1361.45 665.565l-182.58 792.025 3014.73 694.98 389.42-1689.25-96.18-22.171C1505.28 697.438 924.153 757.586 379.305 532.09l-8.277-3.426Z"
|
||||
fill="#FA8372"
|
||||
/>
|
||||
<path
|
||||
d="M359.326 571.714C-104.765 215.795-428.003-32.102-1349.55 255.554l-282.3 1224.596 3047.04 722.01 312.24-1354.467C1411.25 1028.3 834.355 935.995 366.435 577.166l-7.109-5.452Z"
|
||||
fill="#E96856"
|
||||
fill-opacity=".6"
|
||||
/>
|
||||
<path
|
||||
d="M1593.87 1236.88c-352.15 92.63-885.498-145.85-1244.602-613.557l-5.455-7.105C-12.347 152.31-260.41-170.8-1225-131.458l-368.63 1599.048 3057.19 704.76 130.31-935.47Z"
|
||||
fill="#C42652"
|
||||
fill-opacity=".2"
|
||||
/>
|
||||
<path
|
||||
d="M1411.91 1526.93c-363.79 15.71-834.312-330.6-1085.883-863.909l-3.822-8.102C72.704 125.95-101.074-242.476-1052.01-408.907l-699.85 1484.267 2837.75 1338.01 326.02-886.44Z"
|
||||
fill="#A41C42"
|
||||
fill-opacity=".2"
|
||||
/>
|
||||
<path
|
||||
d="M1116.26 1863.69c-355.457-78.98-720.318-535.27-825.287-1115.521l-1.594-8.816C185.286 163.833 112.786-237.016-762.678-643.898L-1822.83 608.665 571.922 2635.55l544.338-771.86Z"
|
||||
fill="#A41C42"
|
||||
fill-opacity=".2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
|
||||
<div class="mx-auto max-w-xl lg:mx-0">
|
||||
<svg viewBox="0 0 71 48" class="h-12" aria-hidden="true">
|
||||
<path
|
||||
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z"
|
||||
fill="#FD4F00"
|
||||
/>
|
||||
</svg>
|
||||
<div class="mt-10 flex justify-between items-center">
|
||||
<h1 class="flex items-center text-sm font-semibold leading-6">
|
||||
Phoenix Framework
|
||||
<small class="badge badge-warning badge-sm ml-3">
|
||||
v{Application.spec(:phoenix, :vsn)}
|
||||
</small>
|
||||
</h1>
|
||||
<Layouts.theme_toggle />
|
||||
</div>
|
||||
|
||||
<p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-balance">
|
||||
Peace of mind from prototype to production.
|
||||
</p>
|
||||
<p class="mt-4 leading-7 text-base-content/70">
|
||||
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.
|
||||
</p>
|
||||
<div class="flex">
|
||||
<div class="w-full sm:w-auto">
|
||||
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3">
|
||||
<a
|
||||
href="https://hexdocs.pm/phoenix/overview.html"
|
||||
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
|
||||
</span>
|
||||
<span class="relative flex items-center gap-4 sm:flex-col">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
|
||||
<path d="m12 4 10-2v18l-10 2V4Z" fill="currentColor" fill-opacity=".15" />
|
||||
<path
|
||||
d="M12 4 2 2v18l10 2m0-18v18m0-18 10-2v18l-10 2"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Guides & Docs
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/phoenixframework/phoenix"
|
||||
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
|
||||
</span>
|
||||
<span class="relative flex items-center gap-4 sm:flex-col">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" class="h-6 w-6">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12 0C5.37 0 0 5.506 0 12.303c0 5.445 3.435 10.043 8.205 11.674.6.107.825-.262.825-.585 0-.292-.015-1.261-.015-2.291C6 21.67 5.22 20.346 4.98 19.654c-.135-.354-.72-1.446-1.23-1.738-.42-.23-1.02-.8-.015-.815.945-.015 1.62.892 1.845 1.261 1.08 1.86 2.805 1.338 3.495 1.015.105-.8.42-1.338.765-1.645-2.67-.308-5.46-1.37-5.46-6.075 0-1.338.465-2.446 1.23-3.307-.12-.308-.54-1.569.12-3.26 0 0 1.005-.323 3.3 1.26.96-.276 1.98-.415 3-.415s2.04.139 3 .416c2.295-1.6 3.3-1.261 3.3-1.261.66 1.691.24 2.952.12 3.26.765.861 1.23 1.953 1.23 3.307 0 4.721-2.805 5.767-5.475 6.075.435.384.81 1.122.81 2.276 0 1.645-.015 2.968-.015 3.383 0 .323.225.707.825.585a12.047 12.047 0 0 0 5.919-4.489A12.536 12.536 0 0 0 24 12.304C24 5.505 18.63 0 12 0Z"
|
||||
/>
|
||||
</svg>
|
||||
Source Code
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href={"https://github.com/phoenixframework/phoenix/blob/v#{Application.spec(:phoenix, :vsn)}/CHANGELOG.md"}
|
||||
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
|
||||
>
|
||||
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
|
||||
</span>
|
||||
<span class="relative flex items-center gap-4 sm:flex-col">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
|
||||
<path
|
||||
d="M12 1v6M12 17v6"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="4"
|
||||
fill="currentColor"
|
||||
fill-opacity=".15"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Changelog
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-10 grid grid-cols-1 gap-y-4 text-sm leading-6 text-base-content/80 sm:grid-cols-2">
|
||||
<div>
|
||||
<a
|
||||
href="https://elixirforum.com"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
|
||||
>
|
||||
<path d="M8 13.833c3.866 0 7-2.873 7-6.416C15 3.873 11.866 1 8 1S1 3.873 1 7.417c0 1.081.292 2.1.808 2.995.606 1.05.806 2.399.086 3.375l-.208.283c-.285.386-.01.905.465.85.852-.098 2.048-.318 3.137-.81a3.717 3.717 0 0 1 1.91-.318c.263.027.53.041.802.041Z" />
|
||||
</svg>
|
||||
Discuss on the Elixir Forum
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://discord.gg/elixir"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
|
||||
>
|
||||
<path d="M13.545 2.995c-1.02-.46-2.114-.8-3.257-.994a.05.05 0 0 0-.052.024c-.141.246-.297.567-.406.82a12.377 12.377 0 0 0-3.658 0 8.238 8.238 0 0 0-.412-.82.052.052 0 0 0-.052-.024 13.315 13.315 0 0 0-3.257.994.046.046 0 0 0-.021.018C.356 6.063-.213 9.036.066 11.973c.001.015.01.029.02.038a13.353 13.353 0 0 0 3.996 1.987.052.052 0 0 0 .056-.018c.308-.414.582-.85.818-1.309a.05.05 0 0 0-.028-.069 8.808 8.808 0 0 1-1.248-.585.05.05 0 0 1-.005-.084c.084-.062.168-.126.248-.191a.05.05 0 0 1 .051-.007c2.619 1.176 5.454 1.176 8.041 0a.05.05 0 0 1 .053.006c.08.065.164.13.248.192a.05.05 0 0 1-.004.084c-.399.23-.813.423-1.249.585a.05.05 0 0 0-.027.07c.24.457.514.893.817 1.307a.051.051 0 0 0 .056.019 13.31 13.31 0 0 0 4.001-1.987.05.05 0 0 0 .021-.037c.334-3.396-.559-6.345-2.365-8.96a.04.04 0 0 0-.021-.02Zm-8.198 7.19c-.789 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.637 1.587-1.438 1.587Zm5.316 0c-.788 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.63 1.587-1.438 1.587Z" />
|
||||
</svg>
|
||||
Join our Discord server
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://elixir-slack.community/"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
|
||||
>
|
||||
<path d="M3.361 10.11a1.68 1.68 0 1 1-1.68-1.681h1.68v1.682ZM4.209 10.11a1.68 1.68 0 1 1 3.361 0v4.21a1.68 1.68 0 1 1-3.361 0v-4.21ZM5.89 3.361a1.68 1.68 0 1 1 1.681-1.68v1.68H5.89ZM5.89 4.209a1.68 1.68 0 1 1 0 3.361H1.68a1.68 1.68 0 1 1 0-3.361h4.21ZM12.639 5.89a1.68 1.68 0 1 1 1.68 1.681h-1.68V5.89ZM11.791 5.89a1.68 1.68 0 1 1-3.361 0V1.68a1.68 1.68 0 0 1 3.361 0v4.21ZM10.11 12.639a1.68 1.68 0 1 1-1.681 1.68v-1.68h1.682ZM10.11 11.791a1.68 1.68 0 1 1 0-3.361h4.21a1.68 1.68 0 1 1 0 3.361h-4.21Z" />
|
||||
</svg>
|
||||
Join us on Slack
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://fly.io/docs/elixir/getting-started/"
|
||||
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
|
||||
>
|
||||
<path d="M1 12.5A4.5 4.5 0 005.5 17H15a4 4 0 001.866-7.539 3.504 3.504 0 00-4.504-4.272A4.5 4.5 0 004.06 8.235 4.502 4.502 0 001 12.5z" />
|
||||
</svg>
|
||||
Deploy your application
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
1
lib/mixer_web/controllers/page_html/index.html.heex
Normal file
1
lib/mixer_web/controllers/page_html/index.html.heex
Normal file
@@ -0,0 +1 @@
|
||||
<div id="app"></div>
|
||||
65
lib/mixer_web/endpoint.ex
Normal file
65
lib/mixer_web/endpoint.ex
Normal file
@@ -0,0 +1,65 @@
|
||||
defmodule MixerWeb.Endpoint do
|
||||
use Phoenix.Endpoint, otp_app: :mixer
|
||||
use Absinthe.Phoenix.Endpoint
|
||||
|
||||
# The session will be stored in the cookie and signed,
|
||||
# this means its contents can be read but not tampered with.
|
||||
# Set :encryption_salt if you would also like to encrypt it.
|
||||
@session_options [
|
||||
store: :cookie,
|
||||
key: "_mixer_key",
|
||||
signing_salt: "oRInhdZg",
|
||||
same_site: "Lax"
|
||||
]
|
||||
|
||||
socket "/live", Phoenix.LiveView.Socket,
|
||||
websocket: [connect_info: [session: @session_options]],
|
||||
longpoll: [connect_info: [session: @session_options]]
|
||||
|
||||
socket "/ws/gql", MixerWeb.GraphqlSocket, websocket: true, longpoll: true
|
||||
|
||||
# Serve at "/" the static files from "priv/static" directory.
|
||||
#
|
||||
# When code reloading is disabled (e.g., in production),
|
||||
# the `gzip` option is enabled to serve compressed
|
||||
# static files generated by running `phx.digest`.
|
||||
plug Plug.Static,
|
||||
at: "/",
|
||||
from: :mixer,
|
||||
gzip: not code_reloading?,
|
||||
only: MixerWeb.static_paths(),
|
||||
raise_on_missing_only: code_reloading?
|
||||
|
||||
# Code reloading can be explicitly enabled under the
|
||||
# :code_reloader configuration of your endpoint.
|
||||
if code_reloading? do
|
||||
plug AshAi.Mcp.Dev,
|
||||
# For many tools, you will need to set the `protocol_version_statement` to the older version.
|
||||
protocol_version_statement: "2024-11-05",
|
||||
otp_app: :mixer,
|
||||
path: "/ash_ai/mcp"
|
||||
|
||||
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
|
||||
plug Phoenix.LiveReloader
|
||||
plug Phoenix.CodeReloader
|
||||
plug AshPhoenix.Plug.CheckCodegenStatus
|
||||
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :mixer
|
||||
end
|
||||
|
||||
plug Phoenix.LiveDashboard.RequestLogger,
|
||||
param_key: "request_logger",
|
||||
cookie_key: "request_logger"
|
||||
|
||||
plug Plug.RequestId
|
||||
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
|
||||
|
||||
plug Plug.Parsers,
|
||||
parsers: [:urlencoded, :multipart, :json, Absinthe.Plug.Parser, AshJsonApi.Plug.Parser],
|
||||
pass: ["*/*"],
|
||||
json_decoder: Phoenix.json_library()
|
||||
|
||||
plug Plug.MethodOverride
|
||||
plug Plug.Head
|
||||
plug Plug.Session, @session_options
|
||||
plug MixerWeb.Router
|
||||
end
|
||||
25
lib/mixer_web/gettext.ex
Normal file
25
lib/mixer_web/gettext.ex
Normal file
@@ -0,0 +1,25 @@
|
||||
defmodule MixerWeb.Gettext do
|
||||
@moduledoc """
|
||||
A module providing Internationalization with a gettext-based API.
|
||||
|
||||
By using [Gettext](https://hexdocs.pm/gettext), your module compiles translations
|
||||
that you can use in your application. To use this Gettext backend module,
|
||||
call `use Gettext` and pass it as an option:
|
||||
|
||||
use Gettext, backend: MixerWeb.Gettext
|
||||
|
||||
# Simple translation
|
||||
gettext("Here is the string to translate")
|
||||
|
||||
# Plural translation
|
||||
ngettext("Here is the string to translate",
|
||||
"Here are the strings to translate",
|
||||
3)
|
||||
|
||||
# Domain-based translation
|
||||
dgettext("errors", "Here is the error message to translate")
|
||||
|
||||
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
|
||||
"""
|
||||
use Gettext.Backend, otp_app: :mixer
|
||||
end
|
||||
29
lib/mixer_web/graphql_schema.ex
Normal file
29
lib/mixer_web/graphql_schema.ex
Normal file
@@ -0,0 +1,29 @@
|
||||
defmodule MixerWeb.GraphqlSchema do
|
||||
use Absinthe.Schema
|
||||
|
||||
use AshGraphql,
|
||||
domains: []
|
||||
|
||||
import_types Absinthe.Plug.Types
|
||||
|
||||
query do
|
||||
# Custom Absinthe queries can be placed here
|
||||
@desc """
|
||||
Hello! This is a sample query to verify that AshGraphql has been set up correctly.
|
||||
Remove me once you have a query of your own!
|
||||
"""
|
||||
field :say_hello, :string do
|
||||
resolve fn _, _, _ ->
|
||||
{:ok, "Hello from AshGraphql!"}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
mutation do
|
||||
# Custom Absinthe mutations can be placed here
|
||||
end
|
||||
|
||||
subscription do
|
||||
# Custom Absinthe subscriptions can be placed here
|
||||
end
|
||||
end
|
||||
14
lib/mixer_web/graphql_socket.ex
Normal file
14
lib/mixer_web/graphql_socket.ex
Normal file
@@ -0,0 +1,14 @@
|
||||
defmodule MixerWeb.GraphqlSocket do
|
||||
use Phoenix.Socket
|
||||
|
||||
use Absinthe.Phoenix.Socket,
|
||||
schema: MixerWeb.GraphqlSchema
|
||||
|
||||
@impl true
|
||||
def connect(_params, socket, _connect_info) do
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def id(_socket), do: nil
|
||||
end
|
||||
39
lib/mixer_web/live_user_auth.ex
Normal file
39
lib/mixer_web/live_user_auth.ex
Normal file
@@ -0,0 +1,39 @@
|
||||
defmodule MixerWeb.LiveUserAuth do
|
||||
@moduledoc """
|
||||
Helpers for authenticating users in LiveViews.
|
||||
"""
|
||||
|
||||
import Phoenix.Component
|
||||
use MixerWeb, :verified_routes
|
||||
|
||||
# This is used for nested liveviews to fetch the current user.
|
||||
# To use, place the following at the top of that liveview:
|
||||
# on_mount {MixerWeb.LiveUserAuth, :current_user}
|
||||
def on_mount(:current_user, _params, session, socket) do
|
||||
{:cont, AshAuthentication.Phoenix.LiveSession.assign_new_resources(socket, session)}
|
||||
end
|
||||
|
||||
def on_mount(:live_user_optional, _params, _session, socket) do
|
||||
if socket.assigns[:current_user] do
|
||||
{:cont, socket}
|
||||
else
|
||||
{:cont, assign(socket, :current_user, nil)}
|
||||
end
|
||||
end
|
||||
|
||||
def on_mount(:live_user_required, _params, _session, socket) do
|
||||
if socket.assigns[:current_user] do
|
||||
{:cont, socket}
|
||||
else
|
||||
{:halt, Phoenix.LiveView.redirect(socket, to: ~p"/sign-in")}
|
||||
end
|
||||
end
|
||||
|
||||
def on_mount(:live_no_user, _params, _session, socket) do
|
||||
if socket.assigns[:current_user] do
|
||||
{:halt, Phoenix.LiveView.redirect(socket, to: ~p"/")}
|
||||
else
|
||||
{:cont, assign(socket, :current_user, nil)}
|
||||
end
|
||||
end
|
||||
end
|
||||
145
lib/mixer_web/router.ex
Normal file
145
lib/mixer_web/router.ex
Normal file
@@ -0,0 +1,145 @@
|
||||
defmodule MixerWeb.Router do
|
||||
use MixerWeb, :router
|
||||
|
||||
use AshAuthentication.Phoenix.Router
|
||||
|
||||
import AshAuthentication.Plug.Helpers
|
||||
|
||||
pipeline :graphql do
|
||||
plug :load_from_bearer
|
||||
plug :set_actor, :user
|
||||
plug AshGraphql.Plug
|
||||
end
|
||||
|
||||
pipeline :browser do
|
||||
plug :accepts, ["html"]
|
||||
plug :fetch_session
|
||||
plug :fetch_live_flash
|
||||
plug :put_root_layout, html: {MixerWeb.Layouts, :root}
|
||||
plug :protect_from_forgery
|
||||
plug :put_secure_browser_headers
|
||||
plug :load_from_session
|
||||
end
|
||||
|
||||
pipeline :api do
|
||||
plug :accepts, ["json"]
|
||||
|
||||
plug AshAuthentication.Strategy.ApiKey.Plug,
|
||||
resource: Mixer.Accounts.User,
|
||||
# if you want to require an api key to be supplied, set `required?` to true
|
||||
required?: false
|
||||
|
||||
plug :load_from_bearer
|
||||
plug :set_actor, :user
|
||||
end
|
||||
|
||||
scope "/", MixerWeb do
|
||||
pipe_through :browser
|
||||
|
||||
ash_authentication_live_session :authenticated_routes do
|
||||
# in each liveview, add one of the following at the top of the module:
|
||||
#
|
||||
# If an authenticated user must be present:
|
||||
# on_mount {MixerWeb.LiveUserAuth, :live_user_required}
|
||||
#
|
||||
# If an authenticated user *may* be present:
|
||||
# on_mount {MixerWeb.LiveUserAuth, :live_user_optional}
|
||||
#
|
||||
# If an authenticated user must *not* be present:
|
||||
# on_mount {MixerWeb.LiveUserAuth, :live_no_user}
|
||||
end
|
||||
|
||||
post "/rpc/run", AshTypescriptRpcController, :run
|
||||
post "/rpc/validate", AshTypescriptRpcController, :validate
|
||||
get "/ash-typescript", PageController, :index
|
||||
end
|
||||
|
||||
scope "/api/json" do
|
||||
pipe_through [:api]
|
||||
|
||||
forward "/swaggerui", OpenApiSpex.Plug.SwaggerUI,
|
||||
path: "/api/json/open_api",
|
||||
default_model_expand_depth: 4
|
||||
|
||||
forward "/", MixerWeb.AshJsonApiRouter
|
||||
end
|
||||
|
||||
scope "/gql" do
|
||||
pipe_through [:graphql]
|
||||
|
||||
forward "/playground", Absinthe.Plug.GraphiQL,
|
||||
schema: Module.concat(["MixerWeb.GraphqlSchema"]),
|
||||
socket: Module.concat(["MixerWeb.GraphqlSocket"]),
|
||||
interface: :simple
|
||||
|
||||
forward "/", Absinthe.Plug, schema: Module.concat(["MixerWeb.GraphqlSchema"])
|
||||
end
|
||||
|
||||
scope "/", MixerWeb do
|
||||
pipe_through :browser
|
||||
|
||||
get "/", PageController, :home
|
||||
auth_routes AuthController, Mixer.Accounts.User, path: "/auth"
|
||||
sign_out_route AuthController
|
||||
|
||||
# Remove these if you'd like to use your own authentication views
|
||||
sign_in_route register_path: "/register",
|
||||
reset_path: "/reset",
|
||||
auth_routes_prefix: "/auth",
|
||||
on_mount: [{MixerWeb.LiveUserAuth, :live_no_user}],
|
||||
overrides: [
|
||||
MixerWeb.AuthOverrides,
|
||||
Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI
|
||||
]
|
||||
|
||||
# Remove this if you do not want to use the reset password feature
|
||||
reset_route auth_routes_prefix: "/auth",
|
||||
overrides: [
|
||||
MixerWeb.AuthOverrides,
|
||||
Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI
|
||||
]
|
||||
|
||||
# Remove this if you do not use the confirmation strategy
|
||||
confirm_route Mixer.Accounts.User, :confirm_new_user,
|
||||
auth_routes_prefix: "/auth",
|
||||
overrides: [MixerWeb.AuthOverrides, Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI]
|
||||
|
||||
# Remove this if you do not use the magic link strategy.
|
||||
magic_sign_in_route(Mixer.Accounts.User, :magic_link,
|
||||
auth_routes_prefix: "/auth",
|
||||
overrides: [MixerWeb.AuthOverrides, Elixir.AshAuthentication.Phoenix.Overrides.DaisyUI]
|
||||
)
|
||||
end
|
||||
|
||||
# Other scopes may use custom stacks.
|
||||
# scope "/api", MixerWeb do
|
||||
# pipe_through :api
|
||||
# end
|
||||
|
||||
# Enable LiveDashboard and Swoosh mailbox preview in development
|
||||
if Application.compile_env(:mixer, :dev_routes) do
|
||||
# If you want to use the LiveDashboard in production, you should put
|
||||
# it behind authentication and allow only admins to access it.
|
||||
# If your application does not have an admins-only section yet,
|
||||
# you can use Plug.BasicAuth to set up some basic authentication
|
||||
# as long as you are also using SSL (which you should anyway).
|
||||
import Phoenix.LiveDashboard.Router
|
||||
|
||||
scope "/dev" do
|
||||
pipe_through :browser
|
||||
|
||||
live_dashboard "/dashboard", metrics: MixerWeb.Telemetry
|
||||
forward "/mailbox", Plug.Swoosh.MailboxPreview
|
||||
end
|
||||
end
|
||||
|
||||
if Application.compile_env(:mixer, :dev_routes) do
|
||||
import AshAdmin.Router
|
||||
|
||||
scope "/admin" do
|
||||
pipe_through :browser
|
||||
|
||||
ash_admin "/"
|
||||
end
|
||||
end
|
||||
end
|
||||
93
lib/mixer_web/telemetry.ex
Normal file
93
lib/mixer_web/telemetry.ex
Normal file
@@ -0,0 +1,93 @@
|
||||
defmodule MixerWeb.Telemetry do
|
||||
use Supervisor
|
||||
import Telemetry.Metrics
|
||||
|
||||
def start_link(arg) do
|
||||
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_arg) do
|
||||
children = [
|
||||
# Telemetry poller will execute the given period measurements
|
||||
# every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
|
||||
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
|
||||
# Add reporters as children of your supervision tree.
|
||||
# {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
|
||||
]
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
|
||||
def metrics do
|
||||
[
|
||||
# Phoenix Metrics
|
||||
summary("phoenix.endpoint.start.system_time",
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.endpoint.stop.duration",
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.router_dispatch.start.system_time",
|
||||
tags: [:route],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.router_dispatch.exception.duration",
|
||||
tags: [:route],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.router_dispatch.stop.duration",
|
||||
tags: [:route],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.socket_connected.duration",
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
sum("phoenix.socket_drain.count"),
|
||||
summary("phoenix.channel_joined.duration",
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.channel_handled_in.duration",
|
||||
tags: [:event],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
|
||||
# Database Metrics
|
||||
summary("mixer.repo.query.total_time",
|
||||
unit: {:native, :millisecond},
|
||||
description: "The sum of the other measurements"
|
||||
),
|
||||
summary("mixer.repo.query.decode_time",
|
||||
unit: {:native, :millisecond},
|
||||
description: "The time spent decoding the data received from the database"
|
||||
),
|
||||
summary("mixer.repo.query.query_time",
|
||||
unit: {:native, :millisecond},
|
||||
description: "The time spent executing the query"
|
||||
),
|
||||
summary("mixer.repo.query.queue_time",
|
||||
unit: {:native, :millisecond},
|
||||
description: "The time spent waiting for a database connection"
|
||||
),
|
||||
summary("mixer.repo.query.idle_time",
|
||||
unit: {:native, :millisecond},
|
||||
description:
|
||||
"The time the connection spent waiting before being checked out for the query"
|
||||
),
|
||||
|
||||
# VM Metrics
|
||||
summary("vm.memory.total", unit: {:byte, :kilobyte}),
|
||||
summary("vm.total_run_queue_lengths.total"),
|
||||
summary("vm.total_run_queue_lengths.cpu"),
|
||||
summary("vm.total_run_queue_lengths.io")
|
||||
]
|
||||
end
|
||||
|
||||
defp periodic_measurements do
|
||||
[
|
||||
# A module, function and arguments to be invoked periodically.
|
||||
# This function must call :telemetry.execute/3 and a metric must be added above.
|
||||
# {MixerWeb, :count_users, []}
|
||||
]
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user