From 05f9dce1e50a5fc6874b0ca47e0df75389eb5232 Mon Sep 17 00:00:00 2001
From: sloane <git@sloanelybutsurely.com>
Date: Sat, 29 Mar 2025 16:15:28 -0400
Subject: [PATCH] really basic new/edit post page and dashboard

---
 lib/core/posts.ex                     | 18 ++++++
 lib/core/posts/post.ex                | 22 +++++++
 lib/web/components/core_components.ex | 12 +++-
 lib/web/live/admin_dashboard_live.ex  | 58 +++++++++++++++++
 lib/web/live/admin_post_live.ex       | 89 +++++++++++++++++++++++++++
 lib/web/router.ex                     |  5 ++
 mix.exs                               |  1 +
 mix.lock                              |  1 +
 8 files changed, 205 insertions(+), 1 deletion(-)
 create mode 100644 lib/web/live/admin_dashboard_live.ex
 create mode 100644 lib/web/live/admin_post_live.ex

diff --git a/lib/core/posts.ex b/lib/core/posts.ex
index fc5721f..5380144 100644
--- a/lib/core/posts.ex
+++ b/lib/core/posts.ex
@@ -19,6 +19,18 @@ defmodule Core.Posts do
     |> Core.Repo.get!(id)
   end
 
+  @recent_posts_count 10
+
+  def get_all_recent_blogs do
+    Post.Query.recent_blogs(@recent_posts_count)
+    |> Core.Repo.all()
+  end
+
+  def get_all_recent_statuses do
+    Post.Query.recent_statuses(@recent_posts_count)
+    |> Core.Repo.all()
+  end
+
   def change_post(%Schema.Post{} = post \\ %Schema.Post{}, attrs) do
     Post.content_changeset(post, attrs)
   end
@@ -35,6 +47,12 @@ defmodule Core.Posts do
     |> Core.Repo.update()
   end
 
+  def create_or_update_post(%Schema.Post{} = post, attrs) do
+    post
+    |> change_post(attrs)
+    |> Core.Repo.insert_or_update()
+  end
+
   def publish_post(%Schema.Post{} = post, published_at \\ DateTime.utc_now()) do
     post
     |> Post.publish_changeset(published_at)
diff --git a/lib/core/posts/post.ex b/lib/core/posts/post.ex
index 52de1f8..2a3b3e3 100644
--- a/lib/core/posts/post.ex
+++ b/lib/core/posts/post.ex
@@ -88,6 +88,28 @@ defmodule Core.Posts.Post do
       where(query, [posts: p], p.kind == ^kind)
     end
 
+    def blogs(query \\ base()) do
+      where_kind(query, :blog)
+    end
+
+    def statuses(query \\ base()) do
+      where_kind(query, :status)
+    end
+
+    def recent_blogs(query \\ base(), count) do
+      query
+      |> blogs()
+      |> default_order()
+      |> limit(^count)
+    end
+
+    def recent_statuses(query \\ base(), count) do
+      query
+      |> statuses()
+      |> default_order()
+      |> limit(^count)
+    end
+
     def where_publish_date_and_slug(query \\ current(), publish_date, slug) do
       where(
         query,
diff --git a/lib/web/components/core_components.ex b/lib/web/components/core_components.ex
index 5340ada..77b5ebf 100644
--- a/lib/web/components/core_components.ex
+++ b/lib/web/components/core_components.ex
@@ -10,7 +10,7 @@ defmodule Web.CoreComponents do
   attr :name, :any
   attr :label, :string, default: nil
   attr :value, :any
-  attr :type, :string, default: "text", values: ~w[text password]
+  attr :type, :string, default: "text", values: ~w[text password textarea]
   attr :field, FormField
   attr :errors, :list, default: []
   attr :rest, :global, include: ~w[disabled form pattern placeholder readonly required]
@@ -34,6 +34,16 @@ defmodule Web.CoreComponents do
     |> input()
   end
 
+  def input(%{type: "textarea"} = assigns) do
+    ~H"""
+    <div>
+      <.label for={@id}>{@label}</.label>
+      <textarea id={@id} name={@name}>{Phoenix.HTML.Form.normalize_value(@type, @value)}</textarea>
+      <.error :for={error <- @errors}>{error}</.error>
+    </div>
+    """
+  end
+
   def input(assigns) do
     ~H"""
     <div>
diff --git a/lib/web/live/admin_dashboard_live.ex b/lib/web/live/admin_dashboard_live.ex
new file mode 100644
index 0000000..5be91bb
--- /dev/null
+++ b/lib/web/live/admin_dashboard_live.ex
@@ -0,0 +1,58 @@
+defmodule Web.AdminDashboardLive do
+  use Web, :live_view
+
+  def mount(_params, _session, socket) do
+    statuses = Core.Posts.get_all_recent_statuses()
+    blogs = Core.Posts.get_all_recent_blogs()
+
+    socket =
+      socket
+      |> stream(:statuses, statuses)
+      |> stream(:blogs, blogs)
+
+    {:ok, socket}
+  end
+
+  def render(assigns) do
+    ~H"""
+    <div class="flex flex-col gap-y-4">
+      <h1 class="font-bold text-2xl">dashboard</h1>
+
+      <section>
+        <header class="flex flex-row justify-between">
+          <h2 class="font-bold text-xl">recent statuses</h2>
+          <.link navigate={~p"/admin/posts/new?kind=status"}>new status</.link>
+        </header>
+        <ol id="recent-statuses" phx-update="stream">
+          <li :for={{dom_id, status} <- @streams.statuses} id={dom_id}>{status.body}</li>
+        </ol>
+      </section>
+
+      <section>
+        <header class="flex flex-row justify-between">
+          <h2 class="font-bold text-xl">recent blogs</h2>
+          <.link navigate={~p"/admin/posts/new?kind=blog"}>new blog</.link>
+        </header>
+        <.post_list :let={blog} id="recent-blogs" stream={@streams.blogs}>
+          <.link href={~p"/admin/posts/#{blog}"}>{blog.title}</.link>
+        </.post_list>
+      </section>
+    </div>
+    """
+  end
+
+  attr :id, :string, required: true
+  attr :stream, :any, required: true
+
+  slot :inner_block, required: true
+
+  def post_list(assigns) do
+    ~H"""
+    <ol id={@id} phx-update="stream">
+      <li :for={{dom_id, item} <- @stream} id={dom_id}>
+        {render_slot(@inner_block, item)}
+      </li>
+    </ol>
+    """
+  end
+end
diff --git a/lib/web/live/admin_post_live.ex b/lib/web/live/admin_post_live.ex
new file mode 100644
index 0000000..99175f9
--- /dev/null
+++ b/lib/web/live/admin_post_live.ex
@@ -0,0 +1,89 @@
+defmodule Web.AdminPostLive do
+  use Web, :live_view
+
+  def mount(_params, _session, socket) do
+    {:ok, socket}
+  end
+
+  def handle_params(%{"post_id" => post_id}, _uri, socket) do
+    post = Core.Posts.get!(post_id)
+    form = Core.Posts.change_post(post, %{}) |> to_form()
+
+    socket = assign(socket, post: post, form: form)
+
+    {:noreply, socket}
+  end
+
+  def handle_params(%{"kind" => kind}, _uri, %{assigns: %{live_action: :new}} = socket)
+      when kind in ~w[blog status] do
+    post = %Schema.Post{kind: String.to_existing_atom(kind)}
+    form = Core.Posts.change_post(post, %{}) |> to_form()
+
+    socket = assign(socket, post: post, form: form)
+
+    {:noreply, socket}
+  end
+
+  def handle_event("validate", %{"post" => attrs}, %{assigns: %{post: post}} = socket) do
+    attrs =
+      if update_slug?(post) and attrs["_unused_slug"] == "" do
+        Map.put(attrs, "slug", Slug.slugify(attrs["title"]))
+      else
+        attrs
+      end
+
+    form = Core.Posts.change_post(post, attrs) |> to_form()
+
+    socket = assign(socket, form: form)
+
+    {:noreply, socket}
+  end
+
+  def handle_event("save", %{"post" => attrs}, %{assigns: %{post: post}} = socket) do
+    socket =
+      case Core.Posts.create_or_update_post(post, attrs) do
+        {:ok, post} ->
+          socket
+          |> put_flash(:info, "post saved")
+          |> push_patch(to: ~p"/admin/posts/#{post}", replace: true)
+
+        {:error, changest} ->
+          assign(socket, form: to_form(changest))
+      end
+
+    {:noreply, socket}
+  end
+
+  def render(assigns) do
+    ~H"""
+    <main>
+      <header>
+        <h1>{page_title(@post, @live_action)}</h1>
+      </header>
+
+      <.form for={@form} phx-change="validate" phx-submit="save">
+        <.input :if={@post.kind == :blog} type="text" field={@form[:title]} />
+        <.input
+          :if={@post.kind == :blog}
+          type="text"
+          field={@form[:slug]}
+          disabled={not update_slug?(@post)}
+        />
+        <.input type="textarea" field={@form[:body]} />
+
+        <.button type="submit">save</.button>
+      </.form>
+    </main>
+    """
+  end
+
+  defp page_title(%Schema.Post{kind: :blog}, :new), do: "new blog"
+  defp page_title(%Schema.Post{kind: :status}, :new), do: "new status"
+  defp page_title(%Schema.Post{kind: :blog}, :edit), do: "edit blog"
+  defp page_title(%Schema.Post{kind: :status}, :edit), do: "edit status"
+
+  defp update_slug?(%Schema.Post{kind: :blog, published_at: published_at}),
+    do: is_nil(published_at)
+
+  defp update_slug?(_), do: true
+end
diff --git a/lib/web/router.ex b/lib/web/router.ex
index 6f05566..a4b51b4 100644
--- a/lib/web/router.ex
+++ b/lib/web/router.ex
@@ -30,6 +30,11 @@ defmodule Web.Router do
 
     live_session :require_authenticated_user, on_mount: [{Web.UserAuth, :ensure_authenticated}] do
       live "/users/settings", UserSettingsLive, :edit
+
+      live "/", AdminDashboardLive
+
+      live "/posts/new", AdminPostLive, :new
+      live "/posts/:post_id", AdminPostLive, :edit
     end
   end
 
diff --git a/mix.exs b/mix.exs
index 5fdb2ab..c9524f2 100644
--- a/mix.exs
+++ b/mix.exs
@@ -59,6 +59,7 @@ defmodule SlaonelyButSurely.MixProject do
       # Added dependencies
       {:boundary, "~> 0.10.4"},
       {:tzdata, "~> 1.1"},
+      {:slugify, "~> 1.3"},
 
       # Added dev and/or test dependencies
       {:credo, "~> 1.7", only: [:dev, :test], runtime: false},
diff --git a/mix.lock b/mix.lock
index 94e7fc0..c70be4d 100644
--- a/mix.lock
+++ b/mix.lock
@@ -40,6 +40,7 @@
   "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
   "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
   "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"},
+  "slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
   "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
   "styler": {:hex, :styler, "1.4.0", "5944723d08afe4d38210b674d7e97dd1137a75968a85a633983cc308e86dc5f2", [:mix], [], "hexpm", "07de0e89c27490c8e469bb814d77ddaaa3283d7d8038501021d80a7705cf13e9"},
   "tailwind": {:hex, :tailwind, "0.2.4", "5706ec47182d4e7045901302bf3a333e80f3d1af65c442ba9a9eed152fb26c2e", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "c6e4a82b8727bab593700c998a4d98cf3d8025678bfde059aed71d0000c3e463"},