add statuses, change navigation

This commit is contained in:
sloane 2025-02-22 15:09:28 -05:00
parent 76ccea9619
commit 574d6a42a0
Signed by: sloanelybutsurely
SSH key fingerprint: SHA256:8SBnwhl+RY3oEyQxy1a9wByPzxWM0x+/Ejc+sIlY5qQ
20 changed files with 326 additions and 95 deletions

View file

@ -7,14 +7,14 @@ defmodule CMS.Posts.Post do
@primary_key {:id, :binary_id, autogenerate: true} @primary_key {:id, :binary_id, autogenerate: true}
schema "posts" do schema "posts" do
field :title, :string field :title, :string
field :contents, :string field :body, :string
timestamps() timestamps()
end end
def changeset(%__MODULE__{} = post, attrs \\ %{}) do def changeset(%__MODULE__{} = post, attrs \\ %{}) do
post post
|> cast(attrs, [:title, :contents]) |> cast(attrs, [:title, :body])
|> validate_required([:contents]) |> validate_required([:body])
end end
end end

31
lib/cms/statuses.ex Normal file
View file

@ -0,0 +1,31 @@
defmodule CMS.Statuses do
@moduledoc false
import Ecto.Query
alias CMS.Repo
alias CMS.Statuses.Status
def create_status(attrs) do
%Status{}
|> Status.changeset(attrs)
|> Repo.insert()
end
def update_status(status, attrs) do
status
|> Status.changeset(attrs)
|> Repo.update()
end
def get_status!(id) do
Repo.get!(Status, id)
end
def list_statuses do
query =
from status in Status,
order_by: [desc: status.inserted_at]
Repo.all(query)
end
end

View file

@ -0,0 +1,19 @@
defmodule CMS.Statuses.Status do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
schema "statuses" do
field :body
timestamps()
end
def changeset(%__MODULE__{} = status, attrs \\ %{}) do
status
|> cast(attrs, [:body])
|> validate_required([:body])
end
end

View file

@ -52,12 +52,25 @@ defmodule CMSWeb.CoreComponents do
attr :class, :string, default: nil attr :class, :string, default: nil
attr :field, Phoenix.HTML.FormField, required: true attr :field, Phoenix.HTML.FormField, required: true
attr :global, :global, include: ~w[required placeholder type] attr :type, :string, default: "text"
attr :global, :global, include: ~w[required placeholder]
def input(%{type: "textarea"} = assigns) do
~H"""
<textarea
class={["px-2 py-1 border border-gray-400 rounded", @class]}
id={@field.id}
name={@field.name}
{@global}
>{@field.value}</textarea>
"""
end
def input(assigns) do def input(assigns) do
~H""" ~H"""
<input <input
class={["px-1 py-0.5 border border-gray-400 rounded", @class]} class={["px-2 py-1 border border-gray-400 rounded", @class]}
type={@type}
id={@field.id} id={@field.id}
name={@field.name} name={@field.name}
value={@field.value} value={@field.value}
@ -95,6 +108,7 @@ defmodule CMSWeb.CoreComponents do
attr :format, :string, required: true attr :format, :string, required: true
attr :value, :any, default: nil attr :value, :any, default: nil
attr :formatter, :atom, default: :default
attr :global, :global attr :global, :global
def timex(%{value: nil} = assigns) do def timex(%{value: nil} = assigns) do
@ -106,8 +120,12 @@ defmodule CMSWeb.CoreComponents do
def timex(assigns) do def timex(assigns) do
~H""" ~H"""
<time datetime={Timex.format!(@value, "{ISO:Extended:Z}")} {@global}> <time datetime={Timex.format!(@value, "{ISO:Extended:Z}")} {@global}>
{Timex.format!(@value, @format)} {Timex.format!(@value, @format, timex_formatter(@formatter))}
</time> </time>
""" """
end end
defp timex_formatter(formatter) do
Module.concat(Timex.Format.DateTime.Formatters, :string.titlecase("#{formatter}"))
end
end end

View file

@ -11,4 +11,10 @@ defmodule CMSWeb.Layouts do
use CMSWeb, :html use CMSWeb, :html
embed_templates "layouts/*" embed_templates "layouts/*"
defp post?("/writing/" <> _), do: true
defp post?(_), do: false
defp status?("/microblog/" <> _), do: true
defp status?(_), do: false
end end

View file

@ -1,50 +1,53 @@
<div <div class="sticky top-0 z-50 flex flex-row bg-white justify-between py-1 md:mb-2 border-b border-gray-200">
:if={@admin?} <div class="flex flex-col md:flex-row">
class="sticky top-0 bg-white z-50 flex flex-row justify-between py-1 px-3 md:mb-2 border-b border-gray-200" <section class="flex flex-row gap-x-2 md:border-r border-gray-200 px-2">
> <.a href={~p"/"} class="font-bold">sloanelybutsurely.com</.a>
<section class="flex flex-row gap-x-2"> <nav>
<div class="pr-2 border-r border-gray-200"> <ul class="flex flex-row gap-x-2">
<.a navigate={~p"/admin"} class="font-bold">admin</.a> <li>
</div> <.a href={~p"/writing"}>writing</.a>
<nav> </li>
<ul class="flex flex-row gap-x-2"> <li>
<li> <.a href={~p"/microblog"}>microblog</.a>
<.a href={~p"/admin/microblog/new"}>new status</.a> </li>
</li> </ul>
<li> </nav>
<.a href={~p"/admin/writing/new"}>new draft</.a> </section>
</li> <section :if={@admin?} class="flex flex-row gap-x-2 px-2">
</ul> <nav>
</nav> <ul class="flex flex-row gap-x-2">
</section> <li>
<.a navigate={~p"/admin"}>admin</.a>
</li>
<li>
<.a href={~p"/admin/statuses/new"}>new status</.a>
</li>
<li>
<.a href={~p"/admin/posts/new"}>new post</.a>
</li>
<li :if={post?(@current_path)}>
<.a href={~p"/admin/posts/#{@post}"}>edit post</.a>
</li>
<li :if={status?(@current_path)}>
<.a href={~p"/admin/statuses/#{@status}"}>edit status</.a>
</li>
</ul>
</nav>
</section>
</div>
<section class="flex flex-row"> <.a :if={@admin?} class="px-2" href={~p"/admin/session/destroy?return_to=#{@current_path}"}>
<.a href={~p"/admin/session/destroy?return_to=#{@current_path}"}> sign out
sign out </.a>
</.a> <.a
</section> :if={!@admin?}
</div> class="px-2 text-transparent hover:text-current"
<div class="flex flex-col md:flex-row mx-auto max-w-4xl"> href={~p"/sign-in?return_to=#{@current_path}"}
<section class="flex flex-col p-2 gap-y-1 border-gray-200 border-b md:border-b-0"> >
<.a href={~p"/"} class="font-bold">sloanelybutsurely.com</.a> sign in
<nav> </.a>
<ul>
<li>
<.a href={~p"/writing"}>writing</.a>
</li>
<li>
<.a href={~p"/microblog"}>microblog</.a>
</li>
</ul>
</nav>
</section>
<main class="p-2 w-full">
{@inner_content}
</main>
</div>
<div
:if={not @admin?}
class="fixed right-0 bottom-0 p-2 text-transparent underline hover:text-current"
>
<.a href={~p"/sign-in?return_to=#{@current_path}"}>sign in</.a>
</div> </div>
<main class="p-2 max-w-2xl mx-auto">
{@inner_content}
</main>

View file

@ -2,12 +2,15 @@ defmodule CMSWeb.PageController do
use CMSWeb, :controller use CMSWeb, :controller
alias CMS.Posts alias CMS.Posts
alias CMS.Statuses
def home(conn, _params) do def home(conn, _params) do
posts = Enum.take(Posts.list_posts(), 5) posts = Enum.take(Posts.list_posts(), 5)
statuses = Enum.take(Statuses.list_statuses(), 10)
conn conn
|> assign(:posts, posts) |> assign(:posts, posts)
|> assign(:statuses, statuses)
|> render(:home) |> render(:home)
end end
end end

View file

@ -1,11 +1,42 @@
<section> <div class="flex flex-col gap-y-4">
<.subtitle> <section>
<.a href={~p"/writing"}>writing</.a> <.title>
</.subtitle> <.a href={~p"/writing"}>writing</.a>
</section> </.title>
<ul class="flex flex-col">
<li :for={post <- @posts}>
<.link href={~p"/writing/#{post}"} class="flex flex-row justify-between group">
<span class="group-hover:underline">
<%= if post.title do %>
{post.title}
<% else %>
(no title)
<% end %>
</span>
<.timex value={post.inserted_at} format="{YYYY}-{0M}-{0D}" class="text-gray-500" />
</.link>
</li>
</ul>
</section>
<section> <section>
<.subtitle> <.title>
<.a href={~p"/microblog"}>microblog</.a> <.a href={~p"/microblog"}>microblog</.a>
</.subtitle> </.title>
</section> <ul class="flex flex-col">
<li :for={status <- @statuses}>
<.link href={~p"/microblog/#{status}"} class="flex flex-row justify-between group">
<span class="group-hover:underline overflow-hidden text-ellipsis text-nowrap">
{status.body}
</span>
<.timex
value={status.inserted_at}
format="{relative}"
formatter={:relative}
class="text-gray-500"
/>
</.link>
</li>
</ul>
</section>
</div>

View file

@ -1,13 +1,15 @@
<.title>writing</.title> <.title>writing</.title>
<ul class="flex flex-col"> <ul class="flex flex-col">
<li :for={post <- @posts} class="flex flex-row justify-between"> <li :for={post <- @posts}>
<.a href={~p"/writing/#{post}"}> <.link href={~p"/writing/#{post}"} class="flex flex-row justify-between group">
<%= if post.title do %> <span class="group-hover:underline">
{post.title} <%= if post.title do %>
<% else %> {post.title}
(no title) <% else %>
<% end %> (no title)
</.a> <% end %>
<.timex value={post.inserted_at} format="{YYYY}-{0M}-{0D}" class="text-gray-500" /> </span>
<.timex value={post.inserted_at} format="{YYYY}-{0M}-{0D}" class="text-gray-500" />
</.link>
</li> </li>
</ul> </ul>

View file

@ -2,7 +2,7 @@
<header class="flex flex-row"> <header class="flex flex-row">
<h1 :if={@post.title} class="font-bold text-xl mb-3">{@post.title}</h1> <h1 :if={@post.title} class="font-bold text-xl mb-3">{@post.title}</h1>
</header> </header>
<section class="prose prose-cms max-w-none">{raw(@post.contents)}</section> <section class="prose prose-cms max-w-none">{raw(@post.body)}</section>
<footer class="mt-5 py-1 border-t border-gray-200 relative"> <footer class="mt-5 py-1 border-t border-gray-200 relative">
<.link :if={@admin?} class="absolute right-0" href={~p"/admin/posts/#{@post}"}>edit</.link> <.link :if={@admin?} class="absolute right-0" href={~p"/admin/posts/#{@post}"}>edit</.link>
</footer> </footer>

View file

@ -0,0 +1,21 @@
defmodule CMSWeb.StatusController do
use CMSWeb, :controller
alias CMS.Statuses
def index(conn, _params) do
statuses = Statuses.list_statuses()
conn
|> assign(:statuses, statuses)
|> render(:index)
end
def show(conn, %{"status_id" => status_id}) do
status = Statuses.get_status!(status_id)
conn
|> assign(:status, status)
|> render(:show)
end
end

View file

@ -0,0 +1,6 @@
defmodule CMSWeb.StatusHTML do
@moduledoc false
use CMSWeb, :html
embed_templates "status_html/*"
end

View file

@ -0,0 +1,16 @@
<.title>microblog</.title>
<ul class="flex flex-col">
<li :for={status <- @statuses}>
<.link href={~p"/microblog/#{status}"} class="flex flex-row justify-between group">
<span class="group-hover:underline overflow-hidden text-ellipsis text-nowrap">
{status.body}
</span>
<.timex
value={status.inserted_at}
format="{relative}"
formatter={:relative}
class="text-gray-500"
/>
</.link>
</li>
</ul>

View file

@ -0,0 +1,3 @@
<article>
<p>{@status.body}</p>
</article>

View file

@ -19,20 +19,8 @@ defmodule CMSWeb.AdminLoginLive do
~H""" ~H"""
<main class="flex flex-col w-screen h-screen fixed justify-center items-center"> <main class="flex flex-col w-screen h-screen fixed justify-center items-center">
<.form for={@form} action={~p"/admin/session/create"} class="flex flex-col gap-y-2"> <.form for={@form} action={~p"/admin/session/create"} class="flex flex-col gap-y-2">
<input <.input type="hidden" field={@form[:return_to]} />
type="hidden" <.input type="password" placeholder="password" field={@form[:password]} required />
id={@form[:return_to].id}
name={@form[:return_to].name}
value={@form[:return_to].value}
/>
<input
type="password"
placeholder="password"
id={@form[:password].id}
name={@form[:password].name}
value={@form[:password].value}
required
/>
<div class="flex flex-col items-end"> <div class="flex flex-col items-end">
<button type="submit" class="font-bold hover:underline">sign in</button> <button type="submit" class="font-bold hover:underline">sign in</button>
<.a href={cancel_href(@return_to)}> <.a href={cancel_href(@return_to)}>

View file

@ -60,10 +60,10 @@ defmodule CMSWeb.PostLive do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<.form for={@form} class="flex flex-col gap-y-2" phx-submit="save_post"> <.form for={@form} class="flex flex-col gap-y-2" phx-submit="save_post">
<.input type="hidden" field={@form[:contents]} /> <.input type="hidden" field={@form[:body]} />
<.input class="text-lg" field={@form[:title]} placeholder="Title" /> <.input class="text-lg" field={@form[:title]} placeholder="Title" />
<div id="editor" phx-update="ignore"> <div id="editor" phx-update="ignore">
<trix-editor input={@form[:contents].id} class="prose prose-cms max-w-none"></trix-editor> <trix-editor input={@form[:body].id} class="prose prose-cms max-w-none"></trix-editor>
</div> </div>
<button type="submit" class="self-end">save</button> <button type="submit" class="self-end">save</button>

View file

@ -0,0 +1,67 @@
defmodule CMSWeb.StatusLive do
@moduledoc false
use CMSWeb, :live_view
alias CMS.Statuses
alias CMS.Statuses.Status
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(_params, uri, %{assigns: %{live_action: :new}} = socket) do
status = %Status{}
changeset = Status.changeset(status)
socket = assign(socket, status: status, form: to_form(changeset))
{:noreply, socket}
end
def handle_params(%{"status_id" => status_id}, _uri, %{assigns: %{live_action: :edit}} = socket) do
status = Statuses.get_status!(status_id)
changeset = Status.changeset(status)
socket = assign(socket, status: status, form: to_form(changeset))
{:noreply, socket}
end
@impl true
def handle_event("save_status", %{"status" => attrs}, %{assigns: %{live_action: :new}} = socket) do
socket =
case Statuses.create_status(attrs) do
{:ok, status} -> push_navigate(socket, to: ~p"/admin/statuses/#{status}")
{:error, changeset} -> assign(socket, form: to_form(changeset))
end
{:noreply, socket}
end
def handle_event("save_status", %{"status" => attrs}, %{assigns: %{status: status, live_action: :edit}} = socket) do
socket =
case Statuses.update_status(status, attrs) do
{:ok, status} ->
assign(socket, status: status, form: to_form(Status.changeset(status)))
{:error, changeset} ->
assign(socket, form: to_form(changeset))
end
{:noreply, socket}
end
@impl true
def render(assigns) do
~H"""
<.form for={@form} class="flex flex-col gap-y-2" phx-submit="save_status">
<.input type="textarea" field={@form[:body]} />
<button type="submit" class="self-end">save</button>
</.form>
"""
end
end

View file

@ -36,6 +36,9 @@ defmodule CMSWeb.Router do
get "/writing", PostController, :index get "/writing", PostController, :index
get "/writing/:post_id", PostController, :show get "/writing/:post_id", PostController, :show
get "/microblog", StatusController, :index
get "/microblog/:status_id", StatusController, :show
live "/sign-in", AdminLoginLive live "/sign-in", AdminLoginLive
post "/admin/session/create", AdminSessionController, :create post "/admin/session/create", AdminSessionController, :create
get "/admin/session/destroy", AdminSessionController, :destroy get "/admin/session/destroy", AdminSessionController, :destroy
@ -47,11 +50,11 @@ defmodule CMSWeb.Router do
live "/", AdminLive live "/", AdminLive
live "/writing/new", PostLive, :new live "/posts/new", PostLive, :new
live "/writing/:post_id", PostLive, :edit live "/posts/:post_id", PostLive, :edit
live "/microblog/new", StatusLive, :new live "/statuses/new", StatusLive, :new
live "/microblog/:status_id", StatusLive, :edit live "/statuses/:status_id", StatusLive, :edit
end end
end end

View file

@ -5,7 +5,7 @@ defmodule CMS.Repo.Migrations.AddPostsTable do
create table(:posts, primary_key: false) do create table(:posts, primary_key: false) do
add :id, :uuid, primary_key: true add :id, :uuid, primary_key: true
add :title, :text add :title, :text
add :contents, :text, null: false, default: "" add :body, :text, null: false, default: ""
timestamps() timestamps()
end end

View file

@ -0,0 +1,14 @@
defmodule CMS.Repo.Migrations.AddStatusesTable do
use Ecto.Migration
def change do
create table(:statuses, primary_key: false) do
add :id, :uuid, primary_key: true
add :body, :text, null: false
timestamps()
end
create index(:statuses, [:inserted_at])
end
end