some reformatting and adjusting so logged in users get moved directly to their feed

This commit is contained in:
2026-04-03 19:40:17 -04:00
parent a926733f1b
commit 874fec835d
19 changed files with 233 additions and 158 deletions

View File

@@ -126,7 +126,17 @@ config :esbuild,
args: args:
~w(js/index.tsx js/app.js --bundle --target=es2022 --outdir=../priv/static/assets --external:/fonts/* --external:/images/* --alias:@=. --splitting --format=esm), ~w(js/index.tsx js/app.js --bundle --target=es2022 --outdir=../priv/static/assets --external:/fonts/* --external:/images/* --alias:@=. --splitting --format=esm),
cd: Path.expand("../assets", __DIR__), cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Enum.join([Path.expand("../deps", __DIR__), Path.expand(Mix.Project.build_path()), Path.expand("../_build/dev", __DIR__)], ":")} env: %{
"NODE_PATH" =>
Enum.join(
[
Path.expand("../deps", __DIR__),
Path.expand(Mix.Project.build_path()),
Path.expand("../_build/dev", __DIR__)
],
":"
)
}
] ]
# Configure tailwind (the version is required) # Configure tailwind (the version is required)

View File

@@ -1,6 +1,18 @@
defmodule Mixer.Accounts do defmodule Mixer.Accounts do
use Ash.Domain, otp_app: :mixer, extensions: [AshTypescript.Rpc, AshAdmin.Domain] use Ash.Domain, otp_app: :mixer, extensions: [AshTypescript.Rpc, AshAdmin.Domain]
typescript_rpc do
resource Mixer.Accounts.User do
rpc_action :read_user, :read
end
resource Mixer.Accounts.Follow do
rpc_action :read_follow, :read
rpc_action :follow_user, :follow
rpc_action :unfollow_user, :unfollow
end
end
admin do admin do
show? true show? true
end end
@@ -12,15 +24,4 @@ defmodule Mixer.Accounts do
resource Mixer.Accounts.Follow resource Mixer.Accounts.Follow
end end
typescript_rpc do
resource Mixer.Accounts.User do
rpc_action :read_user, :read
end
resource Mixer.Accounts.Follow do
rpc_action :read_follow, :read
rpc_action :follow_user, :follow
rpc_action :unfollow_user, :unfollow
end
end
end end

View File

@@ -1,5 +1,6 @@
defmodule Mixer.Accounts.Follow do defmodule Mixer.Accounts.Follow do
require Ash.Query require Ash.Query
use Ash.Resource, use Ash.Resource,
domain: Mixer.Accounts, domain: Mixer.Accounts,
data_layer: AshPostgres.DataLayer, data_layer: AshPostgres.DataLayer,
@@ -20,25 +21,6 @@ defmodule Mixer.Accounts.Follow do
type_name "follows" type_name "follows"
end end
attributes do
uuid_primary_key :id
create_timestamp :created_at
end
relationships do
belongs_to :follower, Mixer.Accounts.User do
primary_key? true
allow_nil? false
attribute_writable? true
end
belongs_to :following, Mixer.Accounts.User do
primary_key? true
allow_nil? false
attribute_writable? true
end
end
actions do actions do
defaults [:read, :destroy] defaults [:read, :destroy]
@@ -48,6 +30,7 @@ defmodule Mixer.Accounts.Follow do
upsert_identity :unique_follow upsert_identity :unique_follow
accept [:following_id] accept [:following_id]
change relate_actor(:follower) change relate_actor(:follower)
validate fn changeset, _context -> validate fn changeset, _context ->
follower_id = Ash.Changeset.get_attribute(changeset, :follower_id) follower_id = Ash.Changeset.get_attribute(changeset, :follower_id)
following_id = Ash.Changeset.get_attribute(changeset, :following_id) following_id = Ash.Changeset.get_attribute(changeset, :following_id)
@@ -82,10 +65,6 @@ defmodule Mixer.Accounts.Follow do
end end
end end
identities do
identity :unique_follow, [:follower_id, :following_id]
end
policies do policies do
policy action_type(:read) do policy action_type(:read) do
authorize_if always() authorize_if always()
@@ -99,4 +78,27 @@ defmodule Mixer.Accounts.Follow do
authorize_if actor_present() authorize_if actor_present()
end end
end end
attributes do
uuid_primary_key :id
create_timestamp :created_at
end
relationships do
belongs_to :follower, Mixer.Accounts.User do
primary_key? true
allow_nil? false
attribute_writable? true
end
belongs_to :following, Mixer.Accounts.User do
primary_key? true
allow_nil? false
attribute_writable? true
end
end
identities do
identity :unique_follow, [:follower_id, :following_id]
end
end end

View File

@@ -31,14 +31,21 @@ defmodule Mixer.Accounts.User.Senders.SendMagicLinkEmail do
defp body(params) do defp body(params) do
# NOTE: You may have to change this to match your magic link acceptance URL. # NOTE: You may have to change this to match your magic link acceptance URL.
link = url(~p"/magic_link/#{params[:token]}") link = url(~p"/magic_link/#{params[:token]}")
email_template("Your magic link", "Hello, #{params[:email]}!", """
<p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;"> email_template(
Use the button below to sign in to Mixer. This link is valid for a short time and can only be used once. "Your magic link",
</p> "Hello, #{params[:email]}!",
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;"> """
If you didn't request this, you can safely ignore this email. <p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;">
</p> Use the button below to sign in to Mixer. This link is valid for a short time and can only be used once.
""", link, "Sign In to Mixer") </p>
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;">
If you didn't request this, you can safely ignore this email.
</p>
""",
link,
"Sign In to Mixer"
)
end end
defp email_template(title, greeting, content, button_url, button_label) do defp email_template(title, greeting, content, button_url, button_label) do

View File

@@ -22,14 +22,21 @@ defmodule Mixer.Accounts.User.Senders.SendNewUserConfirmationEmail do
defp body(params) do defp body(params) do
link = url(~p"/confirm_new_user/#{params[:token]}") link = url(~p"/confirm_new_user/#{params[:token]}")
email_template("Confirm your email", "Welcome to Mixer!", """
<p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;"> email_template(
Thanks for signing up. Just one more step — confirm your email address to activate your account. "Confirm your email",
</p> "Welcome to Mixer!",
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;"> """
If you didn't create an account on Mixer, you can safely ignore this email. <p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;">
</p> Thanks for signing up. Just one more step — confirm your email address to activate your account.
""", link, "Confirm Email Address") </p>
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;">
If you didn't create an account on Mixer, you can safely ignore this email.
</p>
""",
link,
"Confirm Email Address"
)
end end
defp email_template(title, greeting, content, button_url, button_label) do defp email_template(title, greeting, content, button_url, button_label) do

View File

@@ -22,14 +22,21 @@ defmodule Mixer.Accounts.User.Senders.SendPasswordResetEmail do
defp body(params) do defp body(params) do
link = url(~p"/password-reset/#{params[:token]}") link = url(~p"/password-reset/#{params[:token]}")
email_template("Reset your password", "Password reset request", """
<p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;"> email_template(
We received a request to reset the password for your Mixer account. Click the button below to choose a new one. "Reset your password",
</p> "Password reset request",
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;"> """
If you didn't request a password reset, you can safely ignore this email — your password will not change. <p style="margin:0 0 20px 0;color:#4B5563;font-size:16px;line-height:1.6;">
</p> We received a request to reset the password for your Mixer account. Click the button below to choose a new one.
""", link, "Reset My Password") </p>
<p style="margin:0 0 32px 0;color:#4B5563;font-size:16px;line-height:1.6;">
If you didn't request a password reset, you can safely ignore this email — your password will not change.
</p>
""",
link,
"Reset My Password"
)
end end
defp email_template(title, greeting, content, button_url, button_label) do defp email_template(title, greeting, content, button_url, button_label) do

View File

@@ -3,16 +3,6 @@ defmodule Mixer.Posts do
otp_app: :mixer, otp_app: :mixer,
extensions: [AshTypescript.Rpc, AshAdmin.Domain] extensions: [AshTypescript.Rpc, AshAdmin.Domain]
admin do
show? true
end
resources do
resource Mixer.Posts.Tweet
resource Mixer.Posts.TweetLike
resource Mixer.Posts.Media
end
typescript_rpc do typescript_rpc do
resource Mixer.Posts.Tweet do resource Mixer.Posts.Tweet do
rpc_action :create_tweet, :create rpc_action :create_tweet, :create
@@ -27,4 +17,14 @@ defmodule Mixer.Posts do
rpc_action :read_media, :read rpc_action :read_media, :read
end end
end end
admin do
show? true
end
resources do
resource Mixer.Posts.Tweet
resource Mixer.Posts.TweetLike
resource Mixer.Posts.Media
end
end end

View File

@@ -38,6 +38,24 @@ defmodule Mixer.Posts.Media do
end end
end end
policies do
policy action_type(:read) do
authorize_if always()
end
policy action(:upload) do
authorize_if actor_present()
end
policy action(:link_to_tweet) do
authorize_if relates_to_actor_via(:user)
end
policy action_type(:destroy) do
authorize_if relates_to_actor_via(:user)
end
end
attributes do attributes do
uuid_primary_key :id uuid_primary_key :id
@@ -64,22 +82,4 @@ defmodule Mixer.Posts.Media do
public? true public? true
end end
end end
policies do
policy action_type(:read) do
authorize_if always()
end
policy action(:upload) do
authorize_if actor_present()
end
policy action(:link_to_tweet) do
authorize_if relates_to_actor_via(:user)
end
policy action_type(:destroy) do
authorize_if relates_to_actor_via(:user)
end
end
end end

View File

@@ -10,7 +10,8 @@ defmodule Mixer.Posts.MediaUploader do
if ext in @extensions, do: :ok, else: {:error, "unsupported file type #{ext}"} if ext in @extensions, do: :ok, else: {:error, "unsupported file type #{ext}"}
end end
def storage_dir(_version, {_file, scope}), do: "uploads/media/#{scope.user_id}/#{scope.media_id}" def storage_dir(_version, {_file, scope}),
do: "uploads/media/#{scope.user_id}/#{scope.media_id}"
def filename(_version, {file, _scope}) do def filename(_version, {file, _scope}) do
Path.basename(file.file_name, Path.extname(file.file_name)) Path.basename(file.file_name, Path.extname(file.file_name))

View File

@@ -14,10 +14,6 @@ defmodule Mixer.Posts.Tweet do
repo Mixer.Repo repo Mixer.Repo
end end
typescript do
type_name "tweets"
end
state_machine do state_machine do
initial_states [:drafted, :posted] initial_states [:drafted, :posted]
default_initial_state :drafted default_initial_state :drafted
@@ -27,6 +23,10 @@ defmodule Mixer.Posts.Tweet do
end end
end end
typescript do
type_name "tweets"
end
actions do actions do
defaults [:read, :destroy] defaults [:read, :destroy]
@@ -36,6 +36,7 @@ defmodule Mixer.Posts.Tweet do
argument :media_id, :uuid, allow_nil?: true argument :media_id, :uuid, allow_nil?: true
change relate_actor(:user) change relate_actor(:user)
change transition_state(:posted) change transition_state(:posted)
change fn changeset, context -> change fn changeset, context ->
case Ash.Changeset.get_argument(changeset, :media_id) do case Ash.Changeset.get_argument(changeset, :media_id) do
nil -> nil ->
@@ -45,7 +46,9 @@ defmodule Mixer.Posts.Tweet do
Ash.Changeset.after_action(changeset, fn _changeset, tweet -> Ash.Changeset.after_action(changeset, fn _changeset, tweet ->
Mixer.Posts.Media Mixer.Posts.Media
|> Ash.get!(media_id, authorize?: false) |> Ash.get!(media_id, authorize?: false)
|> Ash.Changeset.for_update(:link_to_tweet, %{tweet_id: tweet.id}, actor: context.actor) |> Ash.Changeset.for_update(:link_to_tweet, %{tweet_id: tweet.id},
actor: context.actor
)
|> Ash.update!() |> Ash.update!()
{:ok, tweet} {:ok, tweet}
@@ -111,6 +114,32 @@ defmodule Mixer.Posts.Tweet do
end end
end end
policies do
policy action_type(:read) do
authorize_if always()
end
policy action_type(:create) do
authorize_if actor_present()
end
policy action(:update) do
authorize_if relates_to_actor_via(:user)
end
policy action(:destroy) do
authorize_if relates_to_actor_via(:user)
end
policy action(:like) do
authorize_if actor_present()
end
policy action(:unlike) do
authorize_if actor_present()
end
end
attributes do attributes do
uuid_primary_key :id uuid_primary_key :id
@@ -165,32 +194,6 @@ defmodule Mixer.Posts.Tweet do
end end
end end
policies do
policy action_type(:read) do
authorize_if always()
end
policy action_type(:create) do
authorize_if actor_present()
end
policy action(:update) do
authorize_if relates_to_actor_via(:user)
end
policy action(:destroy) do
authorize_if relates_to_actor_via(:user)
end
policy action(:like) do
authorize_if actor_present()
end
policy action(:unlike) do
authorize_if actor_present()
end
end
defp ensure_like(_tweet, nil), do: {:error, Ash.Error.Forbidden.exception([])} defp ensure_like(_tweet, nil), do: {:error, Ash.Error.Forbidden.exception([])}
defp ensure_like(tweet, actor) do defp ensure_like(tweet, actor) do

View File

@@ -23,6 +23,20 @@ defmodule Mixer.Posts.TweetLike do
end end
end end
policies do
policy action_type(:read) do
authorize_if always()
end
policy action(:create) do
authorize_if actor_present()
end
policy action_type(:destroy) do
authorize_if relates_to_actor_via(:user)
end
end
attributes do attributes do
uuid_primary_key :id uuid_primary_key :id
@@ -52,18 +66,4 @@ defmodule Mixer.Posts.TweetLike do
identities do identities do
identity :unique_user_tweet, [:tweet_id, :user_id] identity :unique_user_tweet, [:tweet_id, :user_id]
end end
policies do
policy action_type(:read) do
authorize_if always()
end
policy action(:create) do
authorize_if actor_present()
end
policy action_type(:destroy) do
authorize_if relates_to_actor_via(:user)
end
end
end end

View File

@@ -2,7 +2,11 @@ defmodule MixerWeb.PageController do
use MixerWeb, :controller use MixerWeb, :controller
def home(conn, _params) do def home(conn, _params) do
render(conn, :home) if conn.assigns[:current_user] do
redirect(conn, to: ~p"/feed")
else
render(conn, :home)
end
end end
def index(conn, _params) do def index(conn, _params) do
@@ -28,11 +32,11 @@ defmodule MixerWeb.PageController do
conn conn
|> put_root_layout(html: {MixerWeb.Layouts, :spa_root}) |> put_root_layout(html: {MixerWeb.Layouts, :spa_root})
|> render(:index, |> render(:index,
current_user: conn.assigns[:current_user], current_user: conn.assigns[:current_user],
media_host: "#{asset_host}/#{bucket}", media_host: "#{asset_host}/#{bucket}",
page: page, page: page,
tweet_id: tweet_id, tweet_id: tweet_id,
user_id: user_id user_id: user_id
) )
end end
end end

View File

@@ -1,8 +1,10 @@
<div id="app" <div
data-current-user-id={if @current_user, do: @current_user.id, else: ""} id="app"
data-current-user-email={if @current_user, do: @current_user.email, else: ""} data-current-user-id={if @current_user, do: @current_user.id, else: ""}
data-asset-host={@media_host} data-current-user-email={if @current_user, do: @current_user.email, else: ""}
data-page={@page} data-asset-host={@media_host}
data-tweet-id={@tweet_id || ""} data-page={@page}
data-user-id={@user_id || ""}> data-tweet-id={@tweet_id || ""}
data-user-id={@user_id || ""}
>
</div> </div>

13
mix.exs
View File

@@ -133,18 +133,21 @@ defmodule Mixer.MixProject do
build: [ build: [
"ash-framework": [ "ash-framework": [
# The description tells people how to use this skill. # The description tells people how to use this skill.
description: "Use this skill working with Ash Framework or any of its extensions. Always consult this when making any domain changes, features or fixes.", description:
"Use this skill working with Ash Framework or any of its extensions. Always consult this when making any domain changes, features or fixes.",
# Include all Ash dependencies # Include all Ash dependencies
usage_rules: [:ash, ~r/^ash_/] usage_rules: [:ash, ~r/^ash_/]
], ],
"phoenix-framework": [ "phoenix-framework": [
description: "Use this skill working with Phoenix Framework. Consult this when working with the web layer, controllers, views, liveviews etc.", description:
"Use this skill working with Phoenix Framework. Consult this when working with the web layer, controllers, views, liveviews etc.",
# Include all Phoenix dependencies # Include all Phoenix dependencies
usage_rules: [:phoenix, ~r/^phoenix_/] usage_rules: [:phoenix, ~r/^phoenix_/]
] ]
] ]
] ]
] ]
[ [
file: "AGENTS.md", file: "AGENTS.md",
usage_rules: ["usage_rules:all"], usage_rules: ["usage_rules:all"],
@@ -152,11 +155,13 @@ defmodule Mixer.MixProject do
location: ".agents/skills", location: ".agents/skills",
build: [ build: [
"ash-framework": [ "ash-framework": [
description: "Use this skill working with Ash Framework or any of its extensions. Always consult this when making any domain changes, features or fixes.", description:
"Use this skill working with Ash Framework or any of its extensions. Always consult this when making any domain changes, features or fixes.",
usage_rules: [:ash, ~r/^ash_/] usage_rules: [:ash, ~r/^ash_/]
], ],
"phoenix-framework": [ "phoenix-framework": [
description: "Use this skill working with Phoenix Framework. Consult this when working with the web layer, controllers, views, liveviews etc.", description:
"Use this skill working with Phoenix Framework. Consult this when working with the web layer, controllers, views, liveviews etc.",
usage_rules: [:phoenix, ~r/^phoenix_/] usage_rules: [:phoenix, ~r/^phoenix_/]
] ]
] ]

View File

@@ -18,7 +18,8 @@ defmodule Mixer.Repo.Migrations.SetupPostsAndTweets do
name: "tweets_user_id_fkey", name: "tweets_user_id_fkey",
type: :uuid, type: :uuid,
prefix: "public" prefix: "public"
), null: false ),
null: false
add :state, :text, null: false, default: "drafted" add :state, :text, null: false, default: "drafted"
end end

View File

@@ -22,7 +22,8 @@ defmodule Mixer.Repo.Migrations.AddPostsMediaS3 do
name: "media_tweet_id_fkey", name: "media_tweet_id_fkey",
type: :uuid, type: :uuid,
prefix: "public" prefix: "public"
), null: false ),
null: false
end end
end end

View File

@@ -17,7 +17,8 @@ defmodule Mixer.Repo.Migrations.AddUserIdToMediaAndAllowNullTweetId do
name: "media_user_id_fkey", name: "media_user_id_fkey",
type: :uuid, type: :uuid,
prefix: "public" prefix: "public"
), null: false ),
null: false
end end
end end

View File

@@ -17,7 +17,8 @@ defmodule Mixer.Repo.Migrations.AddTweetLikes do
name: "tweet_likes_tweet_id_fkey", name: "tweet_likes_tweet_id_fkey",
type: :uuid, type: :uuid,
prefix: "public" prefix: "public"
), null: false ),
null: false
add :user_id, add :user_id,
references(:users, references(:users,
@@ -25,7 +26,8 @@ defmodule Mixer.Repo.Migrations.AddTweetLikes do
name: "tweet_likes_user_id_fkey", name: "tweet_likes_user_id_fkey",
type: :uuid, type: :uuid,
prefix: "public" prefix: "public"
), null: false ),
null: false
end end
create unique_index(:tweet_likes, [:tweet_id, :user_id], create unique_index(:tweet_likes, [:tweet_id, :user_id],

View File

@@ -1,8 +1,29 @@
defmodule MixerWeb.PageControllerTest do defmodule MixerWeb.PageControllerTest do
use MixerWeb.ConnCase use MixerWeb.ConnCase
test "GET /", %{conn: conn} do test "GET / redirects to /feed when logged in", %{conn: conn} do
user =
Mixer.Accounts.User
|> Ash.Changeset.for_create(
:register_with_password,
%{
email: "test@example.com",
password: "Password1!",
password_confirmation: "Password1!"
}, authorize?: false)
|> Ash.create!()
conn =
conn
|> Plug.Test.init_test_session(%{})
|> AshAuthentication.Plug.Helpers.store_in_session(user)
|> get(~p"/")
assert redirected_to(conn) == ~p"/feed"
end
test "GET / renders the home page for unauthenticated users", %{conn: conn} do
conn = get(conn, ~p"/") conn = get(conn, ~p"/")
assert html_response(conn, 200) =~ "Peace of mind from prototype to production" assert html_response(conn, 200) =~ "Mixer"
end end
end end