diff --git a/lib/cms/posts/post.ex b/lib/cms/posts/post.ex index 3918e97..aaaea4f 100644 --- a/lib/cms/posts/post.ex +++ b/lib/cms/posts/post.ex @@ -7,14 +7,14 @@ defmodule CMS.Posts.Post do @primary_key {:id, :binary_id, autogenerate: true} schema "posts" do field :title, :string - field :contents, :string + field :body, :string timestamps() end def changeset(%__MODULE__{} = post, attrs \\ %{}) do post - |> cast(attrs, [:title, :contents]) - |> validate_required([:contents]) + |> cast(attrs, [:title, :body]) + |> validate_required([:body]) end end diff --git a/lib/cms/statuses.ex b/lib/cms/statuses.ex new file mode 100644 index 0000000..7f817f9 --- /dev/null +++ b/lib/cms/statuses.ex @@ -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 diff --git a/lib/cms/statuses/status.ex b/lib/cms/statuses/status.ex new file mode 100644 index 0000000..00cd5f0 --- /dev/null +++ b/lib/cms/statuses/status.ex @@ -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 diff --git a/lib/cms_web/components/core_components.ex b/lib/cms_web/components/core_components.ex index be2c6e5..5cb0e5b 100644 --- a/lib/cms_web/components/core_components.ex +++ b/lib/cms_web/components/core_components.ex @@ -52,12 +52,25 @@ defmodule CMSWeb.CoreComponents do attr :class, :string, default: nil 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 ~H""" <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} name={@field.name} value={@field.value} @@ -95,6 +108,7 @@ defmodule CMSWeb.CoreComponents do attr :format, :string, required: true attr :value, :any, default: nil + attr :formatter, :atom, default: :default attr :global, :global def timex(%{value: nil} = assigns) do @@ -106,8 +120,12 @@ defmodule CMSWeb.CoreComponents do def timex(assigns) do ~H""" <time datetime={Timex.format!(@value, "{ISO:Extended:Z}")} {@global}> - {Timex.format!(@value, @format)} + {Timex.format!(@value, @format, timex_formatter(@formatter))} </time> """ end + + defp timex_formatter(formatter) do + Module.concat(Timex.Format.DateTime.Formatters, :string.titlecase("#{formatter}")) + end end diff --git a/lib/cms_web/components/layouts.ex b/lib/cms_web/components/layouts.ex index 69b254b..4ceac28 100644 --- a/lib/cms_web/components/layouts.ex +++ b/lib/cms_web/components/layouts.ex @@ -11,4 +11,10 @@ defmodule CMSWeb.Layouts do use CMSWeb, :html embed_templates "layouts/*" + + defp post?("/writing/" <> _), do: true + defp post?(_), do: false + + defp status?("/microblog/" <> _), do: true + defp status?(_), do: false end diff --git a/lib/cms_web/components/layouts/app.html.heex b/lib/cms_web/components/layouts/app.html.heex index 6ff7da9..0fffe7f 100644 --- a/lib/cms_web/components/layouts/app.html.heex +++ b/lib/cms_web/components/layouts/app.html.heex @@ -1,50 +1,53 @@ -<div - :if={@admin?} - 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"> - <div class="pr-2 border-r border-gray-200"> - <.a navigate={~p"/admin"} class="font-bold">admin</.a> - </div> - <nav> - <ul class="flex flex-row gap-x-2"> - <li> - <.a href={~p"/admin/microblog/new"}>new status</.a> - </li> - <li> - <.a href={~p"/admin/writing/new"}>new draft</.a> - </li> - </ul> - </nav> - </section> +<div class="sticky top-0 z-50 flex flex-row bg-white justify-between py-1 md:mb-2 border-b border-gray-200"> + <div class="flex flex-col md:flex-row"> + <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> + <nav> + <ul class="flex flex-row gap-x-2"> + <li> + <.a href={~p"/writing"}>writing</.a> + </li> + <li> + <.a href={~p"/microblog"}>microblog</.a> + </li> + </ul> + </nav> + </section> + <section :if={@admin?} class="flex flex-row gap-x-2 px-2"> + <nav> + <ul class="flex flex-row gap-x-2"> + <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 href={~p"/admin/session/destroy?return_to=#{@current_path}"}> - sign out - </.a> - </section> -</div> -<div class="flex flex-col md:flex-row mx-auto max-w-4xl"> - <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> - <nav> - <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> + <.a :if={@admin?} class="px-2" href={~p"/admin/session/destroy?return_to=#{@current_path}"}> + sign out + </.a> + <.a + :if={!@admin?} + class="px-2 text-transparent hover:text-current" + href={~p"/sign-in?return_to=#{@current_path}"} + > + sign in + </.a> </div> + +<main class="p-2 max-w-2xl mx-auto"> + {@inner_content} +</main> diff --git a/lib/cms_web/controllers/page_controller.ex b/lib/cms_web/controllers/page_controller.ex index 944507c..ca7d635 100644 --- a/lib/cms_web/controllers/page_controller.ex +++ b/lib/cms_web/controllers/page_controller.ex @@ -2,12 +2,15 @@ defmodule CMSWeb.PageController do use CMSWeb, :controller alias CMS.Posts + alias CMS.Statuses def home(conn, _params) do posts = Enum.take(Posts.list_posts(), 5) + statuses = Enum.take(Statuses.list_statuses(), 10) conn |> assign(:posts, posts) + |> assign(:statuses, statuses) |> render(:home) end end diff --git a/lib/cms_web/controllers/page_html/home.html.heex b/lib/cms_web/controllers/page_html/home.html.heex index c8f2e27..15a2aa8 100644 --- a/lib/cms_web/controllers/page_html/home.html.heex +++ b/lib/cms_web/controllers/page_html/home.html.heex @@ -1,11 +1,42 @@ -<section> - <.subtitle> - <.a href={~p"/writing"}>writing</.a> - </.subtitle> -</section> +<div class="flex flex-col gap-y-4"> + <section> + <.title> + <.a href={~p"/writing"}>writing</.a> + </.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> - <.subtitle> - <.a href={~p"/microblog"}>microblog</.a> - </.subtitle> -</section> + <section> + <.title> + <.a href={~p"/microblog"}>microblog</.a> + </.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> + </section> +</div> diff --git a/lib/cms_web/controllers/post_html/index.html.heex b/lib/cms_web/controllers/post_html/index.html.heex index 76865e0..f20baf6 100644 --- a/lib/cms_web/controllers/post_html/index.html.heex +++ b/lib/cms_web/controllers/post_html/index.html.heex @@ -1,13 +1,15 @@ <.title>writing</.title> <ul class="flex flex-col"> - <li :for={post <- @posts} class="flex flex-row justify-between"> - <.a href={~p"/writing/#{post}"}> - <%= if post.title do %> - {post.title} - <% else %> - (no title) - <% end %> - </.a> - <.timex value={post.inserted_at} format="{YYYY}-{0M}-{0D}" class="text-gray-500" /> + <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> diff --git a/lib/cms_web/controllers/post_html/show.html.heex b/lib/cms_web/controllers/post_html/show.html.heex index ad7b2a4..991292c 100644 --- a/lib/cms_web/controllers/post_html/show.html.heex +++ b/lib/cms_web/controllers/post_html/show.html.heex @@ -2,7 +2,7 @@ <header class="flex flex-row"> <h1 :if={@post.title} class="font-bold text-xl mb-3">{@post.title}</h1> </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"> <.link :if={@admin?} class="absolute right-0" href={~p"/admin/posts/#{@post}"}>edit</.link> </footer> diff --git a/lib/cms_web/controllers/status_controller.ex b/lib/cms_web/controllers/status_controller.ex new file mode 100644 index 0000000..6bf211e --- /dev/null +++ b/lib/cms_web/controllers/status_controller.ex @@ -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 diff --git a/lib/cms_web/controllers/status_html.ex b/lib/cms_web/controllers/status_html.ex new file mode 100644 index 0000000..e92f149 --- /dev/null +++ b/lib/cms_web/controllers/status_html.ex @@ -0,0 +1,6 @@ +defmodule CMSWeb.StatusHTML do + @moduledoc false + use CMSWeb, :html + + embed_templates "status_html/*" +end diff --git a/lib/cms_web/controllers/status_html/index.html.heex b/lib/cms_web/controllers/status_html/index.html.heex new file mode 100644 index 0000000..c3105a9 --- /dev/null +++ b/lib/cms_web/controllers/status_html/index.html.heex @@ -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> diff --git a/lib/cms_web/controllers/status_html/show.html.heex b/lib/cms_web/controllers/status_html/show.html.heex new file mode 100644 index 0000000..4b8bdf6 --- /dev/null +++ b/lib/cms_web/controllers/status_html/show.html.heex @@ -0,0 +1,3 @@ +<article> + <p>{@status.body}</p> +</article> diff --git a/lib/cms_web/live/admin_login_live.ex b/lib/cms_web/live/admin_login_live.ex index a8f9e1c..9af7136 100644 --- a/lib/cms_web/live/admin_login_live.ex +++ b/lib/cms_web/live/admin_login_live.ex @@ -19,20 +19,8 @@ defmodule CMSWeb.AdminLoginLive do ~H""" <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"> - <input - type="hidden" - 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 - /> + <.input type="hidden" field={@form[:return_to]} /> + <.input type="password" placeholder="password" field={@form[:password]} required /> <div class="flex flex-col items-end"> <button type="submit" class="font-bold hover:underline">sign in</button> <.a href={cancel_href(@return_to)}> diff --git a/lib/cms_web/live/post_live.ex b/lib/cms_web/live/post_live.ex index 253c608..d47e53a 100644 --- a/lib/cms_web/live/post_live.ex +++ b/lib/cms_web/live/post_live.ex @@ -60,10 +60,10 @@ defmodule CMSWeb.PostLive do def render(assigns) do ~H""" <.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" /> <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> <button type="submit" class="self-end">save</button> diff --git a/lib/cms_web/live/status_live.ex b/lib/cms_web/live/status_live.ex new file mode 100644 index 0000000..036ff97 --- /dev/null +++ b/lib/cms_web/live/status_live.ex @@ -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 diff --git a/lib/cms_web/router.ex b/lib/cms_web/router.ex index 699cb21..4d87d96 100644 --- a/lib/cms_web/router.ex +++ b/lib/cms_web/router.ex @@ -36,6 +36,9 @@ defmodule CMSWeb.Router do get "/writing", PostController, :index get "/writing/:post_id", PostController, :show + get "/microblog", StatusController, :index + get "/microblog/:status_id", StatusController, :show + live "/sign-in", AdminLoginLive post "/admin/session/create", AdminSessionController, :create get "/admin/session/destroy", AdminSessionController, :destroy @@ -47,11 +50,11 @@ defmodule CMSWeb.Router do live "/", AdminLive - live "/writing/new", PostLive, :new - live "/writing/:post_id", PostLive, :edit + live "/posts/new", PostLive, :new + live "/posts/:post_id", PostLive, :edit - live "/microblog/new", StatusLive, :new - live "/microblog/:status_id", StatusLive, :edit + live "/statuses/new", StatusLive, :new + live "/statuses/:status_id", StatusLive, :edit end end diff --git a/priv/repo/migrations/20250222164951_add_posts_table.exs b/priv/repo/migrations/20250222164951_add_posts_table.exs index 065b85c..e424163 100644 --- a/priv/repo/migrations/20250222164951_add_posts_table.exs +++ b/priv/repo/migrations/20250222164951_add_posts_table.exs @@ -5,7 +5,7 @@ defmodule CMS.Repo.Migrations.AddPostsTable do create table(:posts, primary_key: false) do add :id, :uuid, primary_key: true add :title, :text - add :contents, :text, null: false, default: "" + add :body, :text, null: false, default: "" timestamps() end diff --git a/priv/repo/migrations/20250222201807_add_statuses_table.exs b/priv/repo/migrations/20250222201807_add_statuses_table.exs new file mode 100644 index 0000000..e57fab2 --- /dev/null +++ b/priv/repo/migrations/20250222201807_add_statuses_table.exs @@ -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