Phoenix Beginner is, of course, running on Phoenix! I've outlined how I created the website so that you can (hopefully!) glean insight from the process when starting your own Phoenix project.
At its heart, Phoenix Beginner is a hand-rolled content management system (CMS).
Follow along to recreate the basic setup I have here which can be repurposed for your own blog or website.
Scaffolding
Let's start by getting the latest version of Phoenix:
mix local.phx
Create a new Phoenix project:
mix phx.new phoenixbeginner --module PhoenixBeginner --app phoenix_beginner
We're going to need user accounts to set up a private writing area. Luckily, Phoenix comes with an authentication generator out of the box:
mix phx.gen.auth Accounts User users
We'll get back to authentication in a bit.
Hide the number of records
Most projects obscure the number of records that they have by using a UUID or a Hashid to replace the auto-increment id
column provided by most databases. While Phoenix Beginner will have very few records to begin with and most of them will be public in any case, it's always nice to be set up for that extra bit of privacy later on.
My personal preference is to use a Snowflake ID, which is overkill for most projects. Skip this or use a Hashid instead unless you're also partial to Snowflakes.
Add Snowflake.ex
Let's modify our mix.exs
dependencies to include a Snowflake generator
def deps do
[{:snowflake, "~> 1.0.0"}]
end
Next we need to set the Snowflake epoch in milliseconds, which we can do by fetching a UTC timestamp from https://www.epochconverter.com/ such as 1667828220000
(the date I started this project).
Modify config.exs
:
config :snowflake,
machine_id: 0,
epoch: 1667828220000 # 2022-11-07 13:37:00Z
Let's start using the new Snowflake ID. I open up lib/phoenix_beginner_accounts/user.ex
and change registration_changeset/3
:
def registration_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email, :password])
|> validate_email(opts)
|> validate_password(opts)
|> put_change(:id, PhoenixBeginner.Utility.generate_id())
end
You'll notice I've set up a Utility module which wraps around the Snowflake id generator:
defmodule PhoenixBeginner.Utility do
def generate_id() do
case Snowflake.next_id() do
{:ok, id} -> id
_ -> raise "ID creation error."
end
end
end
Authentication
The Phoenix generator command we ran earlier gave us a working authentication system. However, there's no need to allow public registration at this time, so I'm going to go ahead and disable that in lib/phoenix-beginner_web/live/user_registration_live.ex
:
def handle_event("save", %{"user" => user_params}, socket) do
{:noreply,
socket
|> put_flash(:error, "Registration currently disabled.")
}
# Disabled for the time being:
# case Accounts.register_user(user_params) do
# {:ok, user} ->
# {:ok, _} =
# Accounts.deliver_user_confirmation_instructions(
# user,
# &url(~p"/users/confirm/#{&1}")
# )
#
# changeset = Accounts.change_user_registration(user)
# {:noreply, assign(socket, trigger_submit: true, changeset: changeset)}
#
# {:error, %Ecto.Changeset{} = changeset} ->
# {:noreply, assign(socket, :changeset, changeset)}
# end
end
Authorisation
Even though I've disabled registration, I know we'll be needing it back in the future once we have editors, authors and collaborators. When that happens everyone should have a different set of permissions. We can do this with a very simple role-based access control setup (RBAC).
Create the database migration
The first order of business is to generate a Role migration file:
defmodule PhoenixBeginner.Repo.Migrations.CreateRoles do
use Ecto.Migration
def change do
create table(:roles) do
add :name, :string, null: false
add :description, :text
timestamps()
end
create unique_index(:roles, :name)
end
end
Create the Role schema
Notice we're using string for our description while in the migration it was text. This is because the same Ecto type supports multiple database types.
defmodule PhoenixBeginner.Accounts.Role do
use Ecto.Schema
import Ecto.Changeset
schema "roles" do
field :name, :string
field :description, :string
timestamps()
end
@doc false
def changeset(role, attrs) do
role
|> cast(attrs, [:name, :description])
|> validate_required([:name])
|> validate_length(:name, min: 3, max: 100)
end
end
Add Pages
Set up public routes
Set up admin routes
Create the authoring experience
Set up the form
Add TinyMCE
Create the reading experience
Sanitise the output
Use components
Add breadcrumbs
Add Prism
Style the time
Or should we say formatting?