annot.at is a service for syncing static websites, such as blogs, to atproto and standard.site annot.at
elixir atproto standardsite
3

Configure Feed

Select the types of activity you want to include in your feed.

Adds posts page

Posts page loaded with feed entries, can publish single or all, also republish existing.

Also some mixed cleanup and improvements.

Johanna Larsson (Jun 25, 2026, 10:20 PM +0100) 8f77d1e8 eee39f16

+748 -20
+7
lib/annot_at/atproto/tid.ex
··· 18 18 new(timestamp) 19 19 end 20 20 21 + @spec at_time(DateTime.t()) :: String.t() 22 + def at_time(%DateTime{} = datetime) do 23 + clock_id = :rand.uniform(1024) - 1 24 + timestamp_μs = DateTime.to_unix(datetime, :microsecond) * 1024 25 + new(timestamp_μs + clock_id) 26 + end 27 + 21 28 @spec new(non_neg_integer()) :: String.t() 22 29 def new(int) do 23 30 int
+7 -3
lib/annot_at/atproto/xrpc.ex
··· 86 86 end 87 87 88 88 defp needs_nonce?(401, headers) do 89 - headers 90 - |> Map.get("www-authenticate") 91 - |> Enum.any?(&String.contains?(&1, "use_dpop_nonce")) 89 + case Map.fetch(headers, "www-authenticate") do 90 + {:ok, headers} -> 91 + Enum.any?(headers, &String.contains?(&1, "use_dpop_nonce")) 92 + 93 + :error -> 94 + false 95 + end 92 96 end 93 97 94 98 defp needs_nonce?(_status, _headers), do: false
+7
lib/annot_at/feeds/entry.ex
··· 16 16 summary: String.t() | nil, 17 17 content: String.t() | nil 18 18 } 19 + 20 + def hash(%__MODULE__{} = entry) do 21 + # Join collapses the potential nils that :crypto.hash doesn't accept. 22 + iodata = Enum.join([entry.title, entry.summary, entry.content]) 23 + hash = :crypto.hash(:sha256, iodata) 24 + Base.encode16(hash, case: :lower) 25 + end 19 26 end
+17
lib/annot_at/publishing.ex
··· 10 10 alias AnnotAt.Accounts.Scope 11 11 alias AnnotAt.Accounts.User 12 12 alias AnnotAt.Atproto.TID 13 + alias AnnotAt.Publishing.Post 13 14 alias AnnotAt.Publishing.Site 14 15 alias AnnotAt.Repo 15 16 ··· 78 79 79 80 site 80 81 |> Site.changeset(attrs) 82 + |> Repo.update() 83 + end 84 + 85 + def list_posts(%Site{id: site_id}) do 86 + Repo.all(from p in Post, where: p.site_id == ^site_id, order_by: [desc: p.inserted_at]) 87 + end 88 + 89 + def create_post(%Site{id: site_id}, attrs) do 90 + %Post{site_id: site_id} 91 + |> Post.changeset(attrs) 92 + |> Repo.insert() 93 + end 94 + 95 + def update_post(%Post{} = post, attrs) do 96 + post 97 + |> Post.changeset(attrs) 81 98 |> Repo.update() 82 99 end 83 100
+35
lib/annot_at/publishing/post.ex
··· 1 + defmodule AnnotAt.Publishing.Post do 2 + use Ecto.Schema 3 + 4 + import Ecto.Changeset 5 + 6 + @type t :: %__MODULE__{ 7 + site_id: integer(), 8 + guid: String.t(), 9 + rkey: String.t(), 10 + content_hash: String.t() 11 + } 12 + 13 + schema "posts" do 14 + # The stable ID of the blog post, or the URL if not available 15 + field :guid, :string 16 + field :rkey, :string 17 + # We store the hash of the content to detect changes in the future 18 + field :content_hash, :string 19 + 20 + belongs_to :site, AnnotAt.Publishing.Site 21 + 22 + timestamps(type: :utc_datetime) 23 + end 24 + 25 + def changeset(post, attrs) do 26 + post 27 + |> cast(attrs, [:guid, :rkey, :content_hash]) 28 + |> validate_required([:guid, :rkey, :content_hash]) 29 + |> validate_length(:guid, max: 2048) 30 + |> validate_length(:rkey, max: 512) 31 + |> validate_length(:content_hash, max: 64) 32 + |> unique_constraint(:guid, name: :posts_site_id_guid_index) 33 + |> foreign_key_constraint(:site_id) 34 + end 35 + end
+101 -16
lib/annot_at_web/components/core_components.ex
··· 87 87 """ 88 88 end 89 89 90 - @doc """ 91 - Renders a button with navigation support. 92 - 93 - ## Examples 94 - 95 - <.button>Send!</.button> 96 - <.button phx-click="go" variant="primary">Send!</.button> 97 - <.button navigate={~p"/"}>Home</.button> 98 - """ 99 - attr :rest, :global, include: ~w(href navigate patch method download name value disabled) 100 - attr :class, :any 101 - attr :variant, :string, values: ~w(primary) 90 + attr :rest, :global, include: ~w(href navigate patch method download name 91 + value disabled) 92 + attr :class, :any, default: nil 93 + attr :variant, :string, default: "ghost", values: ~w(primary secondary accent 94 + ghost) 95 + attr :shadow, :string, default: nil 96 + attr :size, :string, default: "md", values: ~w(sm md lg) 102 97 slot :inner_block, required: true 103 98 104 99 def button(%{rest: rest} = assigns) do 105 - variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"} 100 + variants = %{ 101 + "primary" => "bg-ink text-paper", 102 + "secondary" => "bg-peach-bold text-ink", 103 + "accent" => "bg-sky-bold text-ink", 104 + "ghost" => "bg-paper text-ink hover:bg-ink/5" 105 + } 106 + 107 + shadows = %{ 108 + nil => nil, 109 + "primary" => "shadow-[4px_4px_0px_0px_var(--color-ink)]", 110 + "secondary" => "shadow-[4px_4px_0px_0px_var(--color-peach-bold)]", 111 + "accent" => "shadow-[4px_4px_0px_0px_var(--color-sky-bold)]" 112 + } 113 + 114 + sizes = %{ 115 + "sm" => "px-3 py-1.5 text-xs", 116 + "md" => "px-5 py-2.5 text-sm", 117 + "lg" => "px-6 py-4 text-lg" 118 + } 106 119 107 120 assigns = 108 - assign_new(assigns, :class, fn -> 109 - ["btn", Map.fetch!(variants, assigns[:variant])] 110 - end) 121 + assign(assigns, :class, [ 122 + "inline-flex cursor-pointer items-center justify-center gap-1.5 123 + rounded-xl border-2 border-ink font-bold transition-all hover:-translate-y-0.5 124 + active:translate-y-0 disabled:cursor-not-allowed disabled:opacity-50 125 + disabled:shadow-none disabled:hover:translate-y-0", 126 + Map.fetch!(sizes, assigns[:size]), 127 + Map.fetch!(variants, assigns[:variant]), 128 + Map.fetch!(shadows, assigns[:shadow]), 129 + assigns[:class] 130 + ]) 111 131 112 132 if rest[:href] || rest[:navigate] || rest[:patch] do 113 133 ~H""" ··· 469 489 """ 470 490 end 471 491 492 + @doc """ 493 + A modal dialog roughly based on the old Phoenix scaffold. 494 + 495 + ## Examples 496 + 497 + <.button phx-click={show_modal("confirm")}>Open</.button> 498 + 499 + <.modal id="confirm"> 500 + <h2 class="font-display text-2xl font-bold tracking-tight">Are you 501 + sure?</h2> 502 + <.button phx-click={hide_modal("confirm")}>Cancel</.button> 503 + </.modal> 504 + """ 505 + attr :id, :string, required: true 506 + attr :on_cancel, JS, default: %JS{} 507 + slot :inner_block, required: true 508 + 509 + def modal(assigns) do 510 + ~H""" 511 + <div id={@id} class="relative z-50 hidden"> 512 + <div id={"#{@id}-bg"} class="fixed inset-0 bg-ink/50" aria-hidden="true" /> 513 + <div class="fixed inset-0 flex items-center justify-center p-4"> 514 + <.focus_wrap 515 + id={"#{@id}-content"} 516 + phx-window-keydown={hide_modal(@on_cancel, @id)} 517 + phx-key="escape" 518 + phx-click-away={hide_modal(@on_cancel, @id)} 519 + role="dialog" 520 + aria-modal="true" 521 + class="relative w-full max-w-lg rounded-2xl border-2 border-ink 522 + bg-paper p-6 shadow-[8px_8px_0px_0px_var(--color-ink)]" 523 + > 524 + {render_slot(@inner_block)} 525 + </.focus_wrap> 526 + </div> 527 + </div> 528 + """ 529 + end 530 + 472 531 ## JS Commands 473 532 474 533 def show(js \\ %JS{}, selector) do ··· 490 549 {"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100", 491 550 "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} 492 551 ) 552 + end 553 + 554 + def show_modal(js \\ %JS{}, id) when is_binary(id) do 555 + js 556 + |> JS.show(to: "##{id}") 557 + |> JS.show( 558 + to: "##{id}-bg", 559 + time: 300, 560 + transition: {"transition-opacity ease-out duration-300", "opacity-0", "opacity-100"} 561 + ) 562 + |> show("##{id}-content") 563 + |> JS.add_class("overflow-hidden", to: "body") 564 + |> JS.focus_first(to: "##{id}-content") 565 + end 566 + 567 + def hide_modal(js \\ %JS{}, id) do 568 + js 569 + |> JS.hide( 570 + to: "##{id}-bg", 571 + time: 200, 572 + transition: {"transition-opacity ease-in duration-200", "opacity-100", "opacity-0"} 573 + ) 574 + |> hide("##{id}-content") 575 + |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"}) 576 + |> JS.remove_class("overflow-hidden", to: "body") 577 + |> JS.pop_focus() 493 578 end 494 579 495 580 @doc """
+426
lib/annot_at_web/controllers/live/posts_live.ex
··· 1 + defmodule AnnotAtWeb.PostsLive do 2 + use AnnotAtWeb, :live_view 3 + 4 + alias AnnotAt.Atproto.StandardSite 5 + alias AnnotAt.Atproto.StandardSite.Document 6 + alias AnnotAt.Atproto.TID 7 + alias AnnotAt.Feeds.Client 8 + alias AnnotAt.Feeds.Entry 9 + alias AnnotAt.Publishing 10 + alias AnnotAt.Publishing.Post 11 + alias Phoenix.LiveView.AsyncResult 12 + alias Phoenix.LiveView.JS 13 + 14 + require Logger 15 + 16 + @impl Phoenix.LiveView 17 + def render(assigns) do 18 + ~H""" 19 + <Layouts.dashboard flash={@flash} current_scope={@current_scope} active={:sites}> 20 + <.link navigate={~p"/sites/#{@site.id}"} class="text-sm font-bold 21 + text-ink/50 hover:text-ink"> 22 + ← {@site.url} 23 + </.link> 24 + <h1 class="mt-4 font-display text-3xl font-bold tracking-tight">Posts</h1> 25 + <p class="mt-1 text-ink/60">Publish your blog's posts to the 26 + ATmosphere.</p> 27 + 28 + <.async_result :let={feed} assign={@feed}> 29 + <:loading> 30 + <div class="mt-6 flex items-center gap-2 text-ink/60"> 31 + <.icon name="hero-arrow-path" class="size-5 animate-spin" /> Reading 32 + the feed… 33 + </div> 34 + </:loading> 35 + <:failed :let={_}> 36 + <p class="mt-6 text-sm font-bold text-red-600">Couldn't read the 37 + feed.</p> 38 + </:failed> 39 + 40 + <div 41 + :if={feed.entries != []} 42 + class="mt-6 flex flex-col gap-3 43 + sm:flex-row sm:items-center sm:justify-between" 44 + > 45 + <p class="text-sm font-medium text-ink/55"> 46 + {Enum.count(done(feed.entries, @posts))} of {length(feed.entries)} published 47 + </p> 48 + 49 + <.button 50 + variant="primary" 51 + shadow="secondary" 52 + disabled={ 53 + @publishing_all? or 54 + not any_pending?( 55 + feed.entries, 56 + @posts 57 + ) 58 + } 59 + phx-click={show_modal("publish-all-modal")} 60 + > 61 + <.icon :if={@publishing_all?} name="hero-arrow-path" class="size-5 62 + animate-spin" /> 63 + {if @publishing_all?, do: "Publishing…", else: "Publish all"} 64 + </.button> 65 + </div> 66 + 67 + <p :if={feed.entries == []} class="mt-6 text-ink/55">No posts in this 68 + feed.</p> 69 + 70 + <div :if={pending(feed.entries, @posts) != []} class="mt-6 space-y-3"> 71 + <div 72 + :for={entry <- pending(feed.entries, @posts)} 73 + class="flex items-center justify-between gap-3 rounded-2xl border-2 74 + border-ink bg-paper p-4" 75 + > 76 + <div class="min-w-0"> 77 + <div class="truncate font-bold">{entry.title}</div> 78 + <div :if={entry.published_at} class="mt-0.5 text-xs text-ink/50"> 79 + {Calendar.strftime(entry.published_at, "%b %d, %Y")} 80 + </div> 81 + </div> 82 + 83 + <.button 84 + :if={has_date?(entry)} 85 + variant="primary" 86 + size="sm" 87 + disabled={publishing?(@publishing, entry)} 88 + phx-value-guid={entry.id} 89 + phx-click={ 90 + "select_post" 91 + |> JS.push() 92 + |> show_modal("publish-modal") 93 + } 94 + > 95 + <.icon 96 + :if={publishing?(@publishing, entry)} 97 + name="hero-arrow-path" 98 + class="size-4 animate-spin" 99 + /> 100 + {if publishing?(@publishing, entry), do: "Publishing…", else: "Publish"} 101 + </.button> 102 + <span :if={not has_date?(entry)} class="flex-none text-xs 103 + text-ink/40">No date</span> 104 + </div> 105 + </div> 106 + 107 + <div :if={done(feed.entries, @posts) != []} class="mt-8"> 108 + <div class="text-[11px] font-bold uppercase tracking-widest 109 + text-ink/40">Published</div> 110 + <div class="mt-3 space-y-2"> 111 + <div 112 + :for={entry <- done(feed.entries, @posts)} 113 + class="flex items-center justify-between gap-3 rounded-2xl 114 + border-2 border-ink/15 px-4 py-3" 115 + > 116 + <div class="min-w-0"> 117 + <div class="truncate font-bold text-ink/70"> 118 + {entry.title} 119 + <span class="text-primary/50 text-xs ml-4"> 120 + {entry_to_post(@posts, entry).rkey} 121 + </span> 122 + </div> 123 + <div class="mt-0.5 flex items-center gap-1.5 text-xs 124 + text-ink/45"> 125 + <.icon name="hero-check" class="size-3.5" /> Published 126 + </div> 127 + </div> 128 + 129 + <.button 130 + variant="ghost" 131 + size="sm" 132 + disabled={publishing?(@publishing, entry)} 133 + phx-value-guid={entry.id} 134 + phx-click={ 135 + JS.push("select_post") 136 + |> show_modal("republish-modal") 137 + } 138 + > 139 + <.icon 140 + name="hero-arrow-path" 141 + class={["size-4", publishing?(@publishing, entry) && "animate-spin"]} 142 + /> 143 + {if publishing?(@publishing, entry), do: "Re-publishing…", else: "Re-publish"} 144 + </.button> 145 + </div> 146 + </div> 147 + </div> 148 + 149 + <.confirm_modal 150 + id="publish-modal" 151 + title="Publish this post" 152 + confirm="publish_post" 153 + cta="Publish" 154 + > 155 + This writes a <span class="font-mono text-xs">site.standard.document</span> 156 + record to your atproto repo, making it publicly discoverable. You can re-publish to update it later. 157 + </.confirm_modal> 158 + 159 + <.confirm_modal 160 + id="republish-modal" 161 + title="Re-publish this post" 162 + confirm="republish_post" 163 + cta="Re-publish" 164 + > 165 + This overwrites the existing <span class="font-mono text-xs">site.standard.document</span> 166 + record with the latest content from your feed. 167 + </.confirm_modal> 168 + 169 + <.confirm_modal 170 + id="publish-all-modal" 171 + title="Publish all posts" 172 + confirm="publish_all_post" 173 + cta="Publish all" 174 + > 175 + This writes a <span class="font-mono text-xs">site.standard.document</span> 176 + record for every unpublished post in your feed, making each publicly discoverable. You can re-publish to update them later. 177 + </.confirm_modal> 178 + </.async_result> 179 + </Layouts.dashboard> 180 + """ 181 + end 182 + 183 + @impl Phoenix.LiveView 184 + def mount(%{"id" => id}, _session, socket) do 185 + site = Publishing.get_site!(socket.assigns.current_scope, id) 186 + posts = Map.new(Publishing.list_posts(site), &{&1.guid, &1}) 187 + 188 + socket = 189 + socket 190 + |> assign( 191 + page_title: "Posts", 192 + site: site, 193 + posts: posts, 194 + publishing_all?: false, 195 + selected_guid: nil, 196 + publishing: MapSet.new() 197 + ) 198 + |> load_feed(site) 199 + 200 + {:ok, socket} 201 + end 202 + 203 + @impl Phoenix.LiveView 204 + def handle_event("select_post", %{"guid" => guid}, socket) do 205 + {:noreply, assign(socket, selected_guid: guid)} 206 + end 207 + 208 + def handle_event("publish_post", _params, socket) do 209 + %{selected_guid: guid, site: site, current_scope: scope, feed: feed} = 210 + socket.assigns 211 + 212 + with %AsyncResult{ok?: true, result: %{entries: entries}} <- feed, 213 + %{published_at: %DateTime{}} = entry <- Enum.find(entries, &(&1.id == guid)) do 214 + socket = 215 + socket 216 + |> assign(publishing: MapSet.put(socket.assigns.publishing, guid)) 217 + |> start_async({:publish, guid}, fn -> create_document(scope, site, entry) end) 218 + 219 + {:noreply, socket} 220 + else 221 + reason -> 222 + Logger.warning("PostsLive: failed to publish", reason: inspect(reason)) 223 + {:noreply, put_flash(socket, :error, "Couldn't publish, try again.")} 224 + end 225 + end 226 + 227 + def handle_event("republish_post", _params, socket) do 228 + %{selected_guid: guid, site: site, current_scope: scope, feed: feed, posts: posts} = 229 + socket.assigns 230 + 231 + with %AsyncResult{ok?: true, result: %{entries: entries}} <- feed, 232 + %{published_at: %DateTime{}} = entry <- 233 + Enum.find( 234 + entries, 235 + &(&1.id == 236 + guid) 237 + ), 238 + %Post{} = post <- Map.get(posts, guid) do 239 + socket = 240 + socket 241 + |> assign(publishing: MapSet.put(socket.assigns.publishing, guid)) 242 + |> start_async({:publish, guid}, fn -> update_document(scope, site, post, entry) end) 243 + 244 + {:noreply, socket} 245 + else 246 + reason -> 247 + Logger.warning("PostsLive: failed to re-publish", reason: inspect(reason)) 248 + {:noreply, put_flash(socket, :error, "Couldn't re-publish, try again.")} 249 + end 250 + end 251 + 252 + def handle_event("publish_all_post", _params, socket) do 253 + %{site: site, current_scope: scope, feed: feed, posts: posts} = 254 + socket.assigns 255 + 256 + with %AsyncResult{ok?: true, result: %{entries: entries}} <- feed do 257 + to_publish = 258 + entries 259 + |> pending(posts) 260 + |> Enum.filter(&has_date?/1) 261 + 262 + socket = 263 + socket 264 + |> assign(publishing_all?: true) 265 + |> start_async(:publish_all, fn -> publish_all(scope, site, to_publish) end) 266 + 267 + {:noreply, socket} 268 + end 269 + end 270 + 271 + @impl Phoenix.LiveView 272 + def handle_async(:publish_all, {:ok, new_posts}, socket) do 273 + socket = 274 + socket 275 + |> assign(publishing_all?: false) 276 + |> assign(posts: Map.merge(socket.assigns.posts, new_posts)) 277 + 278 + {:noreply, socket} 279 + end 280 + 281 + def handle_async(:publish_all, {:exit, reason}, socket) do 282 + Logger.warning("PostsLive: failed to publish all", reason: inspect(reason)) 283 + 284 + socket = 285 + socket 286 + |> assign(publishing_all?: false) 287 + |> put_flash(:error, "Some posts couldn't be published.") 288 + 289 + {:noreply, socket} 290 + end 291 + 292 + def handle_async({:publish, guid}, {:ok, {:ok, post}}, socket) do 293 + {:noreply, 294 + assign(socket, 295 + publishing: MapSet.delete(socket.assigns.publishing, guid), 296 + posts: Map.put(socket.assigns.posts, guid, post) 297 + )} 298 + end 299 + 300 + def handle_async({:publish, guid}, result, socket) do 301 + Logger.warning("PostsLive: write failed", reason: inspect(result), guid: guid) 302 + 303 + socket = 304 + socket 305 + |> assign(publishing: MapSet.delete(socket.assigns.publishing, guid)) 306 + |> put_flash(:error, "Couldn't publish, try again.") 307 + 308 + {:noreply, socket} 309 + end 310 + 311 + defp load_feed(socket, site) do 312 + if connected?(socket) do 313 + assign_async(socket, :feed, fn -> 314 + with {:ok, feed} <- Client.load(site.feed_url) do 315 + {:ok, %{feed: feed}} 316 + end 317 + end) 318 + else 319 + assign(socket, feed: AsyncResult.loading()) 320 + end 321 + end 322 + 323 + defp to_document(entry, site, user, rkey) do 324 + %Document{ 325 + rkey: rkey, 326 + site: StandardSite.publication_uri(user.did, site.rkey), 327 + title: entry.title, 328 + path: path_of(entry.url), 329 + published_at: entry.published_at, 330 + description: entry.summary, 331 + text_content: entry.content 332 + } 333 + end 334 + 335 + defp path_of(nil), do: nil 336 + defp path_of(url), do: URI.parse(url).path 337 + 338 + defp create_document(scope, site, entry) do 339 + rkey = TID.at_time(entry.published_at) 340 + document = to_document(entry, site, scope.user, rkey) 341 + 342 + with {:ok, _} <- StandardSite.put_document(scope.user.id, document) do 343 + Publishing.create_post(site, %{ 344 + guid: entry.id, 345 + rkey: rkey, 346 + content_hash: Entry.hash(entry) 347 + }) 348 + end 349 + end 350 + 351 + defp update_document(scope, site, %Post{} = post, entry) do 352 + document = to_document(entry, site, scope.user, post.rkey) 353 + 354 + with {:ok, _} <- StandardSite.put_document(scope.user.id, document) do 355 + Publishing.update_post(post, %{content_hash: Entry.hash(entry)}) 356 + end 357 + end 358 + 359 + defp publish_all(scope, site, entries) do 360 + Enum.reduce(entries, %{}, fn entry, acc -> 361 + case create_document(scope, site, entry) do 362 + {:ok, post} -> Map.put(acc, entry.id, post) 363 + _ -> acc 364 + end 365 + end) 366 + end 367 + 368 + defp pending(entries, posts) do 369 + Enum.reject(entries, &Map.has_key?(posts, &1.id)) 370 + end 371 + 372 + defp done(entries, posts) do 373 + Enum.filter(entries, &Map.has_key?(posts, &1.id)) 374 + end 375 + 376 + defp any_pending?(entries, posts) do 377 + entries 378 + |> pending(posts) 379 + |> Enum.any?(&has_date?/1) 380 + end 381 + 382 + defp has_date?(entry), do: match?(%DateTime{}, entry.published_at) 383 + 384 + defp publishing?(publishing, entry), do: MapSet.member?(publishing, entry.id) 385 + 386 + attr :id, :string, required: true 387 + attr :title, :string, required: true 388 + attr :confirm, :string, required: true 389 + attr :cta, :string, required: true 390 + slot :inner_block, required: true 391 + 392 + defp confirm_modal(assigns) do 393 + ~H""" 394 + <.modal id={@id}> 395 + <h2 class="font-display text-2xl font-bold tracking-tight">{@title}</h2> 396 + <p class="mt-2 text-sm text-ink/70">{render_slot(@inner_block)}</p> 397 + <div class="mt-6 flex justify-end gap-3"> 398 + <.button phx-click={hide_modal(@id)}>Cancel</.button> 399 + <.button 400 + id={"#{@id}-confirm"} 401 + variant="primary" 402 + shadow="secondary" 403 + phx-click={JS.push(@confirm) |> hide_modal(@id)} 404 + > 405 + {@cta} 406 + </.button> 407 + </div> 408 + </.modal> 409 + """ 410 + end 411 + 412 + defp entry_to_post(posts, %Entry{} = entry) do 413 + result = 414 + Enum.find(posts, fn {_key, %Post{} = post} -> 415 + post.guid == entry.id 416 + end) 417 + 418 + case result do 419 + {_key, value} -> 420 + value 421 + 422 + _ -> 423 + nil 424 + end 425 + end 426 + end
+13
lib/annot_at_web/controllers/live/site_live.ex
··· 46 46 <div class="mt-8 space-y-3"> 47 47 <%= case phase(@site) do %> 48 48 <% :done -> %> 49 + <.link 50 + navigate={~p"/sites/#{@site.id}/posts"} 51 + class="group flex items-center justify-between gap-4 rounded-2xl border-2 border-ink bg-paper px-6 py-4 font-bold transition-all hover:-translate-y-0.5 hover:shadow-[4px_4px_0px_0px_var(--color-peach-bold)]" 52 + > 53 + <span class="flex items-center gap-2.5"> 54 + <.icon name="hero-document-text" class="size-5" /> View posts 55 + </span> 56 + <.icon 57 + name="hero-arrow-right" 58 + class="size-5 transition-transform group-hover:translate-x-1" 59 + /> 60 + </.link> 49 61 <.site_cards site={@site} record={@record} feed={@feed} /> 50 62 <% :feed -> %> 51 63 <.feed_step feeds={@feeds} /> ··· 642 654 </:failed> 643 655 644 656 <dl class="space-y-3"> 657 + <.record_field label="rkey" value={@site.rkey} /> 645 658 <.record_field label="Name" value={record["name"]} /> 646 659 <.record_field label="URL" value={record["url"]} /> 647 660 <.record_field label="Description" value={record["description"]} />
+1
lib/annot_at_web/router.ex
··· 38 38 live "/sites", SitesLive 39 39 live "/sites/new", SiteNewLive 40 40 live "/sites/:id", SiteLive 41 + live "/sites/:id/posts", PostsLive 41 42 end 42 43 end 43 44
+1 -1
mix.exs
··· 99 99 "compile --warnings-as-errors", 100 100 "deps.unlock --unused", 101 101 "format", 102 - "test", 102 + "test --warnings-as-errors", 103 103 "credo --strict" 104 104 ] 105 105 ]
+16
priv/repo/migrations/20260622190851_create_posts.exs
··· 1 + defmodule AnnotAt.Repo.Migrations.CreatePosts do 2 + use Ecto.Migration 3 + 4 + def change do 5 + create table(:posts) do 6 + add :site_id, references(:sites, on_delete: :delete_all), null: false 7 + add :guid, :text, null: false 8 + add :rkey, :text, null: false 9 + add :content_hash, :text, null: false 10 + 11 + timestamps(type: :utc_datetime) 12 + end 13 + 14 + create unique_index(:posts, [:site_id, :guid]) 15 + end 16 + end
+18
test/annot_at/publishing_test.exs
··· 4 4 alias AnnotAt.Accounts 5 5 alias AnnotAt.Accounts.Scope 6 6 alias AnnotAt.Publishing 7 + alias AnnotAt.Publishing.Post 7 8 alias AnnotAt.Publishing.Site 8 9 9 10 setup do ··· 119 120 assert {:ok, updated} = Publishing.use_existing_publication(scope, site, "3mope7jyypk22") 120 121 assert "3mope7jyypk22" == updated.rkey 121 122 assert %DateTime{} = updated.published_at 123 + end 124 + 125 + describe "create_post/2" do 126 + test "then list_posts/1 round trips, scoped to the site", %{scope: scope} do 127 + {:ok, site} = Publishing.create_site(scope, "https://example.com") 128 + 129 + assert {:ok, %Post{} = post} = 130 + Publishing.create_post(site, %{ 131 + guid: "abc", 132 + rkey: "3mope", 133 + content_hash: "deadbeef" 134 + }) 135 + 136 + assert post.site_id == site.id 137 + assert [listed] = Publishing.list_posts(site) 138 + assert listed.id == post.id 139 + end 122 140 end 123 141 end
+98
test/annot_at_web/live/posts_live_test.exs
··· 1 + defmodule AnnotAtWeb.PostsLiveTest do 2 + use AnnotAtWeb.ConnCase, async: true 3 + use Mimic 4 + 5 + import Phoenix.LiveViewTest 6 + 7 + alias AnnotAt.Accounts 8 + alias AnnotAt.Accounts.Scope 9 + alias AnnotAt.Atproto.StandardSite 10 + alias AnnotAt.Feeds.Client 11 + alias AnnotAt.Feeds.Entry 12 + alias AnnotAt.Feeds.Feed 13 + alias AnnotAt.Publishing 14 + 15 + setup do 16 + user = create_user() 17 + scope = Scope.for_user(user) 18 + site = create_site(scope) 19 + 20 + %{user: user, site: site} 21 + end 22 + 23 + test "publishing a post writes a document and flips the row", %{ 24 + conn: conn, 25 + user: user, 26 + site: site 27 + } do 28 + title = "First Post" 29 + id = "guid-1" 30 + 31 + feed = %Feed{ 32 + title: "Blog", 33 + entries: [ 34 + %Entry{ 35 + id: id, 36 + url: "https://example.com/posts/first", 37 + title: title, 38 + published_at: ~U[2024-10-02 13:00:00Z], 39 + summary: "a summary", 40 + content: "the body" 41 + } 42 + ] 43 + } 44 + 45 + expect(Client, :load, fn _url -> {:ok, feed} end) 46 + 47 + expect(StandardSite, :put_document, fn user_id, document -> 48 + assert user_id == user.id 49 + assert title == document.title 50 + assert document.rkey =~ ~r/^[234567abcdefghij]/ 51 + {:ok, %{"uri" => "at://x"}} 52 + end) 53 + 54 + {:ok, lv, _html} = 55 + conn 56 + |> init_test_session(%{user_id: user.id}) 57 + |> live(~p"/sites/#{site.id}/posts") 58 + 59 + assert render_async(lv) =~ title 60 + 61 + lv 62 + |> element("button[phx-value-guid='#{id}']") 63 + |> render_click() 64 + 65 + lv 66 + |> element("#publish-modal-confirm") 67 + |> render_click() 68 + 69 + assert render_async(lv) =~ "Published" 70 + assert [%{guid: ^id}] = Publishing.list_posts(site) 71 + end 72 + 73 + defp create_user do 74 + {:ok, user} = 75 + Accounts.upsert_login( 76 + %{did: "did:plc:abc", handle: "alice.test", pds_host: "https://pds.example.com"}, 77 + %{ 78 + auth_server_issuer: "https://bsky.social", 79 + granted_scopes: "atproto", 80 + access_token: "a", 81 + refresh_token: "r", 82 + dpop_private_jwk: "{}", 83 + expires_at: ~U[2026-01-01 01:00:00Z] 84 + } 85 + ) 86 + 87 + user 88 + end 89 + 90 + defp create_site(scope) do 91 + {:ok, site} = Publishing.create_site(scope, "https://example.com") 92 + {:ok, site} = Publishing.use_new_publication(scope, site) 93 + {:ok, site} = Publishing.mark_verified(scope, site) 94 + {:ok, site} = Publishing.mark_published(scope, site) 95 + 96 + site 97 + end 98 + end
+1
test/test_helper.exs
··· 3 3 Mimic.copy(AnnotAt.Atproto.HTTP) 4 4 Mimic.copy(AnnotAt.Atproto.Identity) 5 5 Mimic.copy(AnnotAt.Atproto.Profile) 6 + Mimic.copy(AnnotAt.Atproto.StandardSite) 6 7 Mimic.copy(AnnotAt.Atproto.XRPC) 7 8 Mimic.copy(AnnotAt.Atproto.OAuth.Client) 8 9 Mimic.copy(AnnotAt.Atproto.OAuth.Discovery)