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.

List sites on dashboard and sites index

Ticked off some milestones too. Tons left to do but it's shaping up nicely I think

Johanna Larsson (Jun 21, 2026, 9:03 PM +0100) c08fac72 5dde1947

+159 -11
+2 -2
README.md
··· 5 5 ## TODO 6 6 7 7 - [x] Sign up/sign in with Bluesky 8 - - [ ] Maybe some design? 9 - - [ ] Add site including verification 8 + - [x] Maybe some design? 9 + - [x] Add site including verification 10 10 - [ ] RSS poller 11 11 - [ ] standard.site document reader 12 12 - [ ] Post to Bluesky
+1 -1
lib/annot_at/publishing.ex
··· 15 15 16 16 @spec list_sites(Scope.t()) :: [Site.t()] 17 17 def list_sites(%Scope{user: %User{id: user_id}}) do 18 - Repo.all(from s in Site, where: s.user_id == ^user_id, order_by: [asc: s.inserted_at]) 18 + Repo.all(from s in Site, where: s.user_id == ^user_id, order_by: [desc: s.inserted_at]) 19 19 end 20 20 21 21 def get_site!(%Scope{user: %User{id: user_id}}, id) do
+3 -1
lib/annot_at_web/components/layouts.ex
··· 74 74 <div class="px-3 pt-5 pb-1.5 text-[11px] font-bold uppercase tracking-widest text-ink/40"> 75 75 Publish 76 76 </div> 77 - <.dash_nav_item icon="hero-globe-alt">Sites</.dash_nav_item> 77 + <.dash_nav_item icon="hero-globe-alt" navigate={~p"/sites"} active={@active == :sites}> 78 + Sites 79 + </.dash_nav_item> 78 80 <.dash_nav_item icon="hero-document-text">Posts</.dash_nav_item> 79 81 80 82 <div class="px-3 pt-5 pb-1.5 text-[11px] font-bold uppercase tracking-widest text-ink/40">
+47
lib/annot_at_web/components/site_components.ex
··· 1 + defmodule AnnotAtWeb.SiteComponents do 2 + @moduledoc """ 3 + Shared components for rendering sites. 4 + """ 5 + 6 + use AnnotAtWeb, :html 7 + 8 + alias AnnotAt.Publishing.Site 9 + 10 + attr :site, :map, required: true 11 + 12 + def site_row(assigns) do 13 + ~H""" 14 + <.link 15 + navigate={~p"/sites/#{@site.id}"} 16 + class="block rounded-2xl border-2 border-ink bg-paper p-5 transition-all 17 + hover:-translate-y-0.5 hover:shadow-[5px_5px_0px_0px_var(--color-ink)]" 18 + > 19 + <div class="flex items-center justify-between gap-3"> 20 + <div class="min-w-0"> 21 + <div class="truncate font-display text-lg font-bold 22 + tracking-tight">{@site.url}</div> 23 + <div class="mt-0.5 truncate text-sm 24 + text-ink/50">{@site.feed_url}</div> 25 + </div> 26 + <.status_badge status={Site.status(@site)} /> 27 + </div> 28 + </.link> 29 + """ 30 + end 31 + 32 + attr :status, :atom, required: true 33 + 34 + def status_badge(assigns) do 35 + ~H""" 36 + <span class={[ 37 + "flex-none rounded-full border-2 border-ink px-3 py-0.5 text-xs 38 + font-bold", 39 + @status == :draft && "bg-peach-light", 40 + @status == :verified && "bg-sky-light", 41 + @status == :published && "bg-sky-bold" 42 + ]}> 43 + {@status |> Atom.to_string() |> String.capitalize()} 44 + </span> 45 + """ 46 + end 47 + end
+30 -7
lib/annot_at_web/controllers/live/dashboard_live.ex
··· 1 1 defmodule AnnotAtWeb.DashboardLive do 2 2 use AnnotAtWeb, :live_view 3 3 4 + import AnnotAtWeb.SiteComponents, only: [site_row: 1] 5 + 6 + alias AnnotAt.Publishing 7 + 4 8 @impl Phoenix.LiveView 5 9 def render(assigns) do 6 10 ~H""" ··· 22 26 <div class="mt-8 grid grid-cols-2 gap-4 lg:grid-cols-4"> 23 27 <.stat_card 24 28 label="Sites" 25 - value="0" 29 + value={"#{length(@sites)}"} 26 30 sub="in your account" 27 31 tint="bg-sky-light" 28 32 shadow="shadow-[5px_5px_0px_0px_var(--color-sky-bold)]" ··· 50 54 /> 51 55 </div> 52 56 53 - <h2 class="mt-10 mb-4 text-lg font-bold">Your sites</h2> 57 + <div class="mt-10 mb-4 flex items-center justify-between"> 58 + <h2 class="text-lg font-bold">Your sites</h2> 59 + <.link 60 + :if={@sites != []} 61 + navigate={~p"/sites"} 62 + class="text-sm font-bold text-ink/55 hover:text-ink" 63 + > 64 + View all → 65 + </.link> 66 + </div> 54 67 55 68 <div 56 69 :if={@sites == []} 57 - class="-rotate-1 rounded-2xl border-2 border-ink bg-sky-light p-8 text-center shadow-[8px_8px_0px_0px_var(--color-sky-bold)]" 70 + class="-rotate-1 rounded-2xl border-2 border-ink bg-sky-light p-8 71 + text-center shadow-[8px_8px_0px_0px_var(--color-sky-bold)]" 58 72 > 59 - <div class="mx-auto grid size-14 place-items-center rounded-full border-2 border-ink bg-peach-bold"> 73 + <div class="mx-auto grid size-14 place-items-center rounded-full 74 + border-2 border-ink bg-peach-bold"> 60 75 <.icon name="hero-globe-alt" class="size-7" /> 61 76 </div> 62 77 <p class="mt-4 font-bold">You haven't added any sites yet</p> 63 78 <p class="mx-auto mt-1 max-w-sm text-sm text-ink/65"> 64 - Connect a blog's RSS feed and we'll publish every new post to the ATmosphere. 79 + Connect a blog's RSS feed and we'll publish every new post to the 80 + ATmosphere. 65 81 </p> 66 82 <.link 67 83 navigate={~p"/sites/new"} 68 - class="mt-5 inline-flex items-center gap-1.5 rounded-xl bg-ink px-5 py-2.5 text-sm font-bold text-paper transition-all hover:scale-[1.02] active:scale-[0.98]" 84 + class="mt-5 inline-flex items-center gap-1.5 rounded-xl bg-ink px-5 85 + py-2.5 text-sm font-bold text-paper transition-all hover:scale-[1.02] 86 + active:scale-[0.98]" 69 87 > 70 88 <.icon name="hero-plus" class="size-4" /> Add your first site 71 89 </.link> 72 90 </div> 91 + 92 + <div :if={@sites != []} class="space-y-3"> 93 + <.site_row :for={site <- Enum.take(@sites, 5)} site={site} /> 94 + </div> 73 95 </Layouts.dashboard> 74 96 """ 75 97 end 76 98 77 99 @impl Phoenix.LiveView 78 100 def mount(_params, _session, socket) do 79 - {:ok, assign(socket, page_title: "Dashboard", sites: [])} 101 + sites = Publishing.list_sites(socket.assigns.current_scope) 102 + {:ok, assign(socket, page_title: "Dashboard", sites: sites)} 80 103 end 81 104 end
+75
lib/annot_at_web/controllers/live/sites_live.ex
··· 1 + defmodule AnnotAtWeb.SitesLive do 2 + use AnnotAtWeb, :live_view 3 + 4 + import AnnotAtWeb.SiteComponents, only: [site_row: 1] 5 + 6 + alias AnnotAt.Publishing 7 + alias AnnotAt.Publishing.Site 8 + 9 + @impl Phoenix.LiveView 10 + def render(assigns) do 11 + assigns = assign(assigns, :visible, filter_sites(assigns.sites, assigns.filter)) 12 + 13 + ~H""" 14 + <Layouts.dashboard flash={@flash} current_scope={@current_scope} active={:sites}> 15 + <div class="flex flex-col gap-4 sm:flex-row sm:items-center 16 + sm:justify-between"> 17 + <h1 class="font-display text-3xl font-bold tracking-tight">Sites</h1> 18 + <.link 19 + navigate={~p"/sites/new"} 20 + class="inline-flex items-center gap-1.5 self-start rounded-xl border-2 21 + border-ink bg-ink px-5 py-2.5 text-sm font-bold text-paper 22 + shadow-[4px_4px_0px_0px_var(--color-peach-bold)] transition-all 23 + hover:-translate-y-0.5 active:translate-y-0" 24 + > 25 + <.icon name="hero-plus" class="size-5" /> Add a site 26 + </.link> 27 + </div> 28 + 29 + <div class="mt-6 flex flex-wrap gap-2"> 30 + <button 31 + :for={f <- ["all", "draft", "verified", "published"]} 32 + phx-click="filter" 33 + phx-value-status={f} 34 + class={[ 35 + "rounded-full border-2 border-ink px-4 py-1.5 text-sm font-bold 36 + transition-all", 37 + @filter == f && "bg-ink text-paper", 38 + @filter != f && "bg-paper hover:bg-sky-light" 39 + ]} 40 + > 41 + {filter_label(f)} 42 + </button> 43 + </div> 44 + 45 + <div class="mt-6 space-y-3"> 46 + <p :if={@visible == []} class="text-ink/55">No sites here yet.</p> 47 + <.site_row :for={site <- @visible} site={site} /> 48 + </div> 49 + </Layouts.dashboard> 50 + """ 51 + end 52 + 53 + @impl Phoenix.LiveView 54 + def mount(_params, _session, socket) do 55 + sites = Publishing.list_sites(socket.assigns.current_scope) 56 + {:ok, assign(socket, page_title: "Sites", sites: sites, filter: "all")} 57 + end 58 + 59 + @impl Phoenix.LiveView 60 + def handle_event("filter", %{"status" => status}, socket) do 61 + {:noreply, assign(socket, filter: String.to_existing_atom(status))} 62 + end 63 + 64 + defp filter_sites(sites, "all"), do: sites 65 + 66 + defp filter_sites(sites, status) do 67 + Enum.filter(sites, &(Site.status(&1) == status)) 68 + end 69 + 70 + defp filter_label("all"), do: "All" 71 + 72 + defp filter_label(status) do 73 + String.capitalize(status) 74 + end 75 + end
+1
lib/annot_at_web/router.ex
··· 35 35 live_session :authenticated, 36 36 on_mount: [{AnnotAtWeb.UserAuth, :require_authenticated}] do 37 37 live "/dashboard", DashboardLive 38 + live "/sites", SitesLive 38 39 live "/sites/new", SiteNewLive 39 40 live "/sites/:id", SiteLive 40 41 end