From f1560ff0e7a52f872fd56a95409e01881a7dfdc6 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 1 Apr 2026 21:42:45 +0000 Subject: [PATCH] Add LiveView editor dashboard with drafts and scheduled tabs --- .beads/issues.jsonl | 2 +- .../live/editor_dashboard_live.ex | 113 ++++++++++++++++++ app/lib/firehose_web/router.ex | 5 + app/lib/firehose_web/user_auth.ex | 37 ++++++ .../live/editor_dashboard_live_test.exs | 88 ++++++++++++++ app/test/support/fake_blog.ex | 65 ++++++++++ 6 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 app/lib/firehose_web/live/editor_dashboard_live.ex create mode 100644 app/test/firehose_web/live/editor_dashboard_live_test.exs create mode 100644 app/test/support/fake_blog.ex diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 39f5146..936da91 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -8,5 +8,5 @@ {"id":"firehose-apw","title":"Add integration tests for scheduled post filtering in Phoenix","description":"## Context\nPhoenix blog controller tests need to verify date filtering works end-to-end.\nMay need a far-future markdown test fixture (2099/01-01-future-post.md).\n\n## Scope\n- app/test/firehose_web/controllers/blog_test.exs\n- app/priv/blog/engineering/2099/01-01-future-post.md (test fixture)\n\n## TDD\nRED: Blog index hides future post, show page returns it, tag page excludes it\nGREEN: Should pass from Blogex changes\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.294363414Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:35:39.95804435Z","dependencies":[{"issue_id":"firehose-apw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.797645635Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-apw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.829112074Z","created_by":"Willem van den Ende"}]} {"id":"firehose-dhh","title":"Run mix phx.gen.auth and configure","description":"## Context\nNo auth exists. Run mix phx.gen.auth Accounts User users.\nRemove auth links from public nav (login/registration are hidden URLs).\n\n## Scope\n- Generated files in app/lib/firehose/accounts/, app/lib/firehose_web/\n- app/lib/firehose_web/router.ex\n- Layout files (root.html.heex, app.html.heex) — remove injected auth links\n\n## TDD\nRED: Generated tests should pass\nGREEN: Run generator, migrate, verify\nREFACTOR: Remove auth links from public navigation","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.010843844Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:31:20.37861782Z","closed_at":"2026-04-01T20:31:20.37861782Z","close_reason":"Closed"} {"id":"firehose-pp3","title":"Seed demo user in dev","description":"## Context\nSeed demo@example.com / password123 in dev environment only.\nUse Accounts context from phx.gen.auth.\n\n## Scope\n- app/priv/repo/seeds.exs\n\n## TDD\nTrivial — manual verification","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:28.091149857Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:37:09.561290121Z","closed_at":"2026-04-01T21:37:09.561290121Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-pp3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.537294098Z","created_by":"Willem van den Ende"}]} -{"id":"firehose-ra3","title":"Show draft/scheduled status banners for authenticated users","description":"## Context\nWhen authenticated user views a draft or scheduled post via direct URL,\nshow a banner: \"Draft — not published\" or \"This post is scheduled for {date}\".\nUnauthenticated users see no banner.\n\n## Scope\n- app/lib/firehose_web/controllers/blog_controller.ex: pass visibility to template\n- app/lib/firehose_web/controllers/blog_html/show.html.heex: conditional banner\n- app/test/firehose_web/controllers/blog_test.exs: banner tests\n\n## TDD\nRED: Auth user sees banner on draft/scheduled, no banner on live, unauth sees no banner\nGREEN: Compute visibility, pass to template, render conditionally\nREFACTOR: Extract banner component if reusable","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.713739919Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T20:32:40.675871251Z","dependencies":[{"issue_id":"firehose-ra3","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.660225195Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-ra3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.696919105Z","created_by":"Willem van den Ende"}]} +{"id":"firehose-ra3","title":"Show draft/scheduled status banners for authenticated users","description":"## Context\nWhen authenticated user views a draft or scheduled post via direct URL,\nshow a banner: \"Draft — not published\" or \"This post is scheduled for {date}\".\nUnauthenticated users see no banner.\n\n## Scope\n- app/lib/firehose_web/controllers/blog_controller.ex: pass visibility to template\n- app/lib/firehose_web/controllers/blog_html/show.html.heex: conditional banner\n- app/test/firehose_web/controllers/blog_test.exs: banner tests\n\n## TDD\nRED: Auth user sees banner on draft/scheduled, no banner on live, unauth sees no banner\nGREEN: Compute visibility, pass to template, render conditionally\nREFACTOR: Extract banner component if reusable","status":"closed","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:44.713739919Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:40:21.809364236Z","closed_at":"2026-04-01T21:40:21.809364236Z","close_reason":"Closed","dependencies":[{"issue_id":"firehose-ra3","depends_on_id":"firehose-4nq","type":"blocks","created_at":"2026-04-01T20:08:01.660225195Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-ra3","depends_on_id":"firehose-dhh","type":"blocks","created_at":"2026-04-01T20:08:01.696919105Z","created_by":"Willem van den Ende"}]} {"id":"firehose-vyw","title":"Verify router respects date filtering","description":"## Context\nBlogex.Router index, tag, and feed routes use all_posts()/posts_by_tag() (now filtered).\nThe /:slug route uses get_post() (now unfiltered). Add tests confirming correct behaviour.\n\n## Scope\n- blogex/test/blogex/router_test.exs\n\n## TDD\nRED: Test GET / excludes future posts, GET /tag/:tag excludes, GET /:slug returns future post\nGREEN: Should pass from Steps 1-2\nREFACTOR: None","status":"in_progress","priority":2,"issue_type":"task","owner":"willem+gitea@livingsoftware.co.uk","created_at":"2026-04-01T20:07:16.253169962Z","created_by":"Willem van den Ende","updated_at":"2026-04-01T21:35:39.918341344Z","dependencies":[{"issue_id":"firehose-vyw","depends_on_id":"firehose-2wc","type":"blocks","created_at":"2026-04-01T20:07:52.73739353Z","created_by":"Willem van den Ende"},{"issue_id":"firehose-vyw","depends_on_id":"firehose-1x3","type":"blocks","created_at":"2026-04-01T20:07:52.770379034Z","created_by":"Willem van den Ende"}]} diff --git a/app/lib/firehose_web/live/editor_dashboard_live.ex b/app/lib/firehose_web/live/editor_dashboard_live.ex new file mode 100644 index 0000000..5f5898f --- /dev/null +++ b/app/lib/firehose_web/live/editor_dashboard_live.ex @@ -0,0 +1,113 @@ +defmodule FirehoseWeb.EditorDashboardLive do + use FirehoseWeb, :live_view + + alias Blogex.Post + + @impl true + def mount(_params, _session, socket) do + all_posts = Blogex.Registry.all_posts_unfiltered() + + drafts = + all_posts + |> Enum.filter(&(Post.visibility(&1) == :draft)) + |> Enum.sort_by(& &1.date, {:desc, Date}) + + scheduled = + all_posts + |> Enum.filter(&(Post.visibility(&1) == :scheduled)) + |> Enum.sort_by(& &1.date, {:asc, Date}) + + {:ok, + socket + |> assign(:page_title, "Editor Dashboard") + |> assign(:drafts, drafts) + |> assign(:scheduled, scheduled) + |> assign(:active_tab, :drafts)} + end + + @impl true + def render(assigns) do + ~H""" +
+

Dashboard

+ +
+ + +
+ +
+
No drafts
+
+
+
+ <.link + navigate={post_path(post)} + class="text-base font-medium text-zinc-900 hover:underline" + > + {post.title} + +
+ {post.author} · {Calendar.strftime(post.date, "%b %d, %Y")} · Draft +
+
+
+
+
+ +
+
No scheduled posts
+
+
+
+ <.link + navigate={post_path(post)} + class="text-base font-medium text-zinc-900 hover:underline" + > + {post.title} + +
+ {post.author} · {Calendar.strftime(post.date, "%b %d, %Y")} · {Post.days_until_live(post)} days until live +
+
+
+
+
+
+ """ + end + + @impl true + def handle_event("switch_tab", %{"tab" => tab}, socket) do + {:noreply, assign(socket, :active_tab, String.to_existing_atom(tab))} + end + + defp post_path(post) do + blog = Blogex.Registry.get_blog!(post.blog) + "#{blog.base_path()}/#{post.id}" + end +end diff --git a/app/lib/firehose_web/router.ex b/app/lib/firehose_web/router.ex index 64c8d15..ff96fd4 100644 --- a/app/lib/firehose_web/router.ex +++ b/app/lib/firehose_web/router.ex @@ -67,6 +67,11 @@ defmodule FirehoseWeb.Router do scope "/", FirehoseWeb do pipe_through [:browser, :require_authenticated_user] + live_session :authenticated_user, + on_mount: [{FirehoseWeb.UserAuth, :ensure_authenticated}] do + live "/editor/dashboard", EditorDashboardLive + end + get "/users/settings", UserSettingsController, :edit put "/users/settings", UserSettingsController, :update get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email diff --git a/app/lib/firehose_web/user_auth.ex b/app/lib/firehose_web/user_auth.ex index 497f038..96bdfb4 100644 --- a/app/lib/firehose_web/user_auth.ex +++ b/app/lib/firehose_web/user_auth.ex @@ -216,4 +216,41 @@ defmodule FirehoseWeb.UserAuth do end defp maybe_store_return_to(conn), do: conn + + @doc """ + LiveView on_mount callback that ensures the user is authenticated. + + Used in `live_session` blocks in the router: + + live_session :authenticated, on_mount: [{FirehoseWeb.UserAuth, :ensure_authenticated}] do + live "/editor/dashboard", EditorDashboardLive + end + """ + def on_mount(:ensure_authenticated, _params, session, socket) do + socket = mount_current_scope(socket, session) + + if socket.assigns.current_scope && socket.assigns.current_scope.user do + {:cont, socket} + else + socket = + socket + |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.") + |> Phoenix.LiveView.redirect(to: ~p"/users/log-in") + + {:halt, socket} + end + end + + defp mount_current_scope(socket, session) do + Phoenix.Component.assign_new(socket, :current_scope, fn -> + if token = session["user_token"] do + case Accounts.get_user_by_session_token(token) do + {user, _token_inserted_at} -> Scope.for_user(user) + nil -> Scope.for_user(nil) + end + else + Scope.for_user(nil) + end + end) + end end diff --git a/app/test/firehose_web/live/editor_dashboard_live_test.exs b/app/test/firehose_web/live/editor_dashboard_live_test.exs new file mode 100644 index 0000000..462dd9b --- /dev/null +++ b/app/test/firehose_web/live/editor_dashboard_live_test.exs @@ -0,0 +1,88 @@ +defmodule FirehoseWeb.EditorDashboardLiveTest do + use FirehoseWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + setup do + posts = [ + %Blogex.Post{ + id: "live-post", + title: "Live Post", + author: "Test Author", + body: "

Body

", + description: "A live post", + date: ~D[2020-01-01], + published: true, + blog: :test_blog, + tags: [] + }, + %Blogex.Post{ + id: "draft-post", + title: "Draft Post", + author: "Test Author", + body: "

Body

", + description: "A draft post", + date: ~D[2026-03-12], + published: false, + blog: :test_blog, + tags: [] + }, + %Blogex.Post{ + id: "scheduled-post", + title: "Scheduled Post", + author: "Test Author", + body: "

Body

", + description: "A scheduled post", + date: ~D[2099-06-15], + published: true, + blog: :test_blog, + tags: ["future"] + } + ] + + {:ok, _} = + Firehose.Test.FakeBlog.start(posts, + blog_id: :test_blog, + title: "Test Blog", + base_path: "/blog/test" + ) + + Application.put_env(:blogex, :blogs, [Firehose.Test.FakeBlog]) + on_exit(fn -> Application.delete_env(:blogex, :blogs) end) + + :ok + end + + describe "unauthenticated" do + test "redirects to login", %{conn: conn} do + assert {:error, redirect} = live(conn, ~p"/editor/dashboard") + assert {:redirect, %{to: to}} = redirect + assert to =~ "/users/log-in" + end + end + + describe "authenticated" do + setup :register_and_log_in_user + + test "renders the dashboard", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/editor/dashboard") + assert html =~ "Dashboard" + end + + test "shows draft posts", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/editor/dashboard") + assert html =~ "Draft Post" + end + + test "shows scheduled posts with days until live", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/editor/dashboard") + assert html =~ "Scheduled Post" + assert html =~ "days" + end + + test "does not show live posts", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/editor/dashboard") + refute html =~ "Live Post" + end + end +end diff --git a/app/test/support/fake_blog.ex b/app/test/support/fake_blog.ex new file mode 100644 index 0000000..2515f95 --- /dev/null +++ b/app/test/support/fake_blog.ex @@ -0,0 +1,65 @@ +defmodule Firehose.Test.FakeBlog do + @moduledoc """ + A test double that implements the blog module interface, + backed by an Agent so tests can control the post data. + """ + + use Agent + + @defaults [ + blog_id: :test_blog, + title: "Test Blog", + description: "A blog for tests", + base_path: "/blog/test" + ] + + def start(posts \\ [], opts \\ []) do + opts = Keyword.merge(@defaults, opts) + + state = %{ + posts: posts, + blog_id: opts[:blog_id], + title: opts[:title], + description: opts[:description], + base_path: opts[:base_path] + } + + case Agent.start(fn -> state end, name: __MODULE__) do + {:ok, pid} -> + {:ok, pid} + + {:error, {:already_started, pid}} -> + Agent.update(__MODULE__, fn _ -> state end) + {:ok, pid} + end + end + + defp get(key), do: Agent.get(__MODULE__, &Map.fetch!(&1, key)) + + def blog_id, do: get(:blog_id) + def title, do: get(:title) + def description, do: get(:description) + def base_path, do: get(:base_path) + + def all_posts_unfiltered do + get(:posts) + |> Enum.sort_by(& &1.date, {:desc, Date}) + end + + def unfiltered_posts do + all_posts_unfiltered() + end + + def all_posts do + get(:posts) + |> Enum.filter(& &1.published) + |> Enum.sort_by(& &1.date, {:desc, Date}) + end + + def all_tags do + all_posts() + |> Enum.flat_map(& &1.tags) + |> Enum.uniq() + |> Enum.sort() + end +end