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"},