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 the dashboard page

Will want to iterate on this but it matches the design aesthetic and it's a decent starting point. Not sure about the stats cards.

Also styles the login page

Johanna Larsson (Jun 18, 2026, 8:33 AM +0100) 3bb4edb5 8ba5ad7e

+368 -27
+110
lib/annot_at_web/components/layouts.ex
··· 44 44 end 45 45 46 46 @doc """ 47 + The authenticated dashboard shell: sidebar nav + main content slot. 48 + """ 49 + attr :flash, :map, required: true 50 + attr :current_scope, :map, required: true 51 + attr :active, :atom, default: :overview, doc: "the active nav item" 52 + slot :inner_block, required: true 53 + 54 + def dashboard(assigns) do 55 + ~H""" 56 + <div class="flex min-h-screen bg-paper text-ink"> 57 + <aside class="flex w-60 flex-none flex-col border-r-2 border-ink p-4"> 58 + <.link 59 + navigate={~p"/dashboard"} 60 + class="px-3 py-2 font-display text-xl font-bold tracking-tight" 61 + > 62 + annot.at 63 + </.link> 64 + 65 + <nav class="mt-4 flex flex-col gap-1"> 66 + <.dash_nav_item 67 + navigate={~p"/dashboard"} 68 + icon="hero-squares-2x2" 69 + active={@active == :overview} 70 + > 71 + Overview 72 + </.dash_nav_item> 73 + 74 + <div class="px-3 pt-5 pb-1.5 text-[11px] font-bold uppercase tracking-widest text-ink/40"> 75 + Publish 76 + </div> 77 + <.dash_nav_item icon="hero-globe-alt">Sites</.dash_nav_item> 78 + <.dash_nav_item icon="hero-document-text">Posts</.dash_nav_item> 79 + 80 + <div class="px-3 pt-5 pb-1.5 text-[11px] font-bold uppercase tracking-widest text-ink/40"> 81 + Account 82 + </div> 83 + <.dash_nav_item icon="hero-cog-6-tooth">Settings</.dash_nav_item> 84 + </nav> 85 + 86 + <div class="mt-auto border-t-2 border-ink/10 pt-3"> 87 + <div class="flex items-center gap-3 px-3 py-2"> 88 + <img 89 + :if={@current_scope.user.avatar_url} 90 + src={@current_scope.user.avatar_url} 91 + alt="" 92 + class="size-9 flex-none rounded-full border-2 border-ink" 93 + /> 94 + <div 95 + :if={!@current_scope.user.avatar_url} 96 + class="size-9 flex-none rounded-full border-2 border-ink bg-gradient-to-br from-sky-bold to-peach-bold" 97 + /> 98 + <div class="min-w-0"> 99 + <div class="truncate text-sm font-bold leading-tight"> 100 + {@current_scope.user.display_name || @current_scope.user.handle} 101 + </div> 102 + <div class="truncate text-xs text-ink/55">{"@" <> @current_scope.user.handle}</div> 103 + </div> 104 + </div> 105 + <.link 106 + href={~p"/logout"} 107 + method="delete" 108 + class="block px-3 py-1.5 text-[11px] font-bold uppercase tracking-widest text-ink/45 hover:text-ink" 109 + > 110 + Sign out 111 + </.link> 112 + </div> 113 + </aside> 114 + 115 + <main class="flex-1 px-6 py-8 sm:px-10"> 116 + {render_slot(@inner_block)} 117 + </main> 118 + </div> 119 + 120 + <.flash_group flash={@flash} /> 121 + """ 122 + end 123 + 124 + attr :icon, :string, required: true 125 + attr :active, :boolean, default: false 126 + attr :navigate, :string, default: nil 127 + slot :inner_block, required: true 128 + 129 + defp dash_nav_item(%{navigate: nil} = assigns) do 130 + ~H""" 131 + <span class="flex cursor-default items-center gap-3 rounded-xl border-2 border-transparent px-3 py-2 text-sm font-semibold text-ink/35"> 132 + <.icon name={@icon} class="size-5" /> 133 + {render_slot(@inner_block)} 134 + </span> 135 + """ 136 + end 137 + 138 + defp dash_nav_item(assigns) do 139 + ~H""" 140 + <.link 141 + navigate={@navigate} 142 + class={[ 143 + "flex items-center gap-3 rounded-xl border-2 px-3 py-2 text-sm font-semibold transition", 144 + if(@active, 145 + do: "border-ink bg-peach-bold text-ink", 146 + else: "border-transparent text-ink/65 hover:bg-ink/5" 147 + ) 148 + ]} 149 + > 150 + <.icon name={@icon} class="size-5" /> 151 + {render_slot(@inner_block)} 152 + </.link> 153 + """ 154 + end 155 + 156 + @doc """ 47 157 Shows the flash group with standard titles and content. 48 158 49 159 ## Examples
+43 -12
lib/annot_at_web/controllers/auth_html/new.html.heex
··· 1 1 <Layouts.app flash={@flash} current_scope={@current_scope}> 2 - <div class="mx-auto max-w-sm space-y-6 py-12"> 3 - <h1 class="text-2xl font-semibold">Sign in with Bluesky</h1> 2 + <div class="flex min-h-screen flex-col items-center justify-center px-6 py-12"> 3 + <.link href={~p"/"} class="mb-8 font-display text-2xl font-bold tracking-tight"> 4 + annot.at 5 + </.link> 6 + 7 + <div class="w-full max-w-sm -rotate-1 rounded-3xl border-2 border-ink bg-paper p-8 shadow-[10px_10px_0px_0px_var(--color-sky-bold)] transition-transform focus-within:rotate-0"> 8 + <h1 class="font-display text-3xl font-bold tracking-tight">Sign in</h1> 9 + <p class="mt-2 text-sm text-ink/60"> 10 + Enter your atproto handle to publish to the ATmosphere. 11 + </p> 12 + 13 + <.form for={%{}} action={~p"/login"} method="post" id="login-form" class="mt-6 space-y-4"> 14 + <div> 15 + <label for="handle" class="mb-1.5 block text-sm font-bold">Handle</label> 16 + <input 17 + type="text" 18 + name="handle" 19 + id="handle" 20 + value={@handle} 21 + placeholder="alice.bsky.social" 22 + required 23 + autofocus 24 + autocapitalize="none" 25 + autocorrect="off" 26 + spellcheck="false" 27 + class="w-full rounded-xl border-2 border-ink bg-paper px-4 py-3 placeholder:text-ink/35 focus:outline-none focus:ring-4 focus:ring-sky-bold/40" 28 + /> 29 + </div> 30 + <button 31 + type="submit" 32 + class="w-full rounded-xl bg-ink px-5 py-3 font-bold text-paper transition-all hover:scale-[1.01] active:scale-[0.99]" 33 + > 34 + Continue 35 + </button> 36 + </.form> 37 + 38 + <p class="mt-5 text-center text-xs text-ink/50"> 39 + Any atproto handle works, Bluesky or your own domain. 40 + </p> 41 + </div> 4 42 5 - <.form for={%{}} action={~p"/login"} method="post" id="login-form" class="space-y-4"> 6 - <.input 7 - name="handle" 8 - value={@handle} 9 - label="Handle" 10 - placeholder="alice.bsky.social" 11 - required 12 - /> 13 - <.button class="w-full">Continue</.button> 14 - </.form> 43 + <.link href={~p"/"} class="mt-8 text-sm text-ink/55 transition hover:text-ink"> 44 + ← Back to home 45 + </.link> 15 46 </div> 16 47 </Layouts.app>
+102
lib/annot_at_web/controllers/live/dashboard_live.ex
··· 1 + defmodule AnnotAtWeb.DashboardLive do 2 + use AnnotAtWeb, :live_view 3 + 4 + @impl Phoenix.LiveView 5 + def mount(_params, _session, socket) do 6 + {:ok, assign(socket, page_title: "Dashboard", sites: [])} 7 + end 8 + 9 + @impl Phoenix.LiveView 10 + def handle_event("add_site", _params, socket) do 11 + {:noreply, put_flash(socket, :info, "Adding sites is coming next.")} 12 + end 13 + 14 + @impl Phoenix.LiveView 15 + def render(assigns) do 16 + ~H""" 17 + <Layouts.dashboard flash={@flash} current_scope={@current_scope} active={:overview}> 18 + <div class="flex justify-end"> 19 + <button 20 + phx-click="add_site" 21 + class="inline-flex items-center gap-1.5 rounded-xl bg-ink px-4 py-2.5 text-sm font-bold text-paper transition-all hover:scale-[1.02] active:scale-[0.98]" 22 + > 23 + <.icon name="hero-plus" class="size-4" /> Add a site 24 + </button> 25 + </div> 26 + 27 + <h1 class="mt-2 font-display text-3xl font-bold tracking-tight sm:text-4xl"> 28 + Hi {@current_scope.user.display_name || @current_scope.user.handle} 29 + </h1> 30 + <p class="mt-2 text-ink/60">Here's what you're publishing to the ATmosphere.</p> 31 + 32 + <div class="mt-8 grid grid-cols-2 gap-4 lg:grid-cols-4"> 33 + <.stat_card 34 + label="Sites" 35 + value="0" 36 + sub="in your account" 37 + tint="bg-sky-light" 38 + shadow="shadow-[5px_5px_0px_0px_var(--color-sky-bold)]" 39 + /> 40 + <.stat_card 41 + label="Posts" 42 + value="0" 43 + sub="published" 44 + tint="bg-peach-light" 45 + shadow="shadow-[5px_5px_0px_0px_var(--color-peach-bold)]" 46 + /> 47 + <.stat_card 48 + label="This week" 49 + value="0" 50 + sub="last 7 days" 51 + tint="bg-sky-light" 52 + shadow="shadow-[5px_5px_0px_0px_var(--color-sky-bold)]" 53 + /> 54 + <.stat_card 55 + label="Bluesky" 56 + value="Off" 57 + sub="cross-posting" 58 + tint="bg-peach-light" 59 + shadow="shadow-[5px_5px_0px_0px_var(--color-peach-bold)]" 60 + /> 61 + </div> 62 + 63 + <h2 class="mt-10 mb-4 text-lg font-bold">Your sites</h2> 64 + 65 + <div 66 + :if={@sites == []} 67 + class="max-w-xl -rotate-1 rounded-2xl border-2 border-ink bg-sky-light p-8 text-center shadow-[8px_8px_0px_0px_var(--color-sky-bold)]" 68 + > 69 + <div class="mx-auto grid size-14 place-items-center rounded-full border-2 border-ink bg-peach-bold"> 70 + <.icon name="hero-globe-alt" class="size-7" /> 71 + </div> 72 + <p class="mt-4 font-bold">You haven't added any sites yet</p> 73 + <p class="mx-auto mt-1 max-w-sm text-sm text-ink/65"> 74 + Connect a blog's RSS feed and we'll publish every new post to the ATmosphere. 75 + </p> 76 + <button 77 + phx-click="add_site" 78 + 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]" 79 + > 80 + <.icon name="hero-plus" class="size-4" /> Add your first site 81 + </button> 82 + </div> 83 + </Layouts.dashboard> 84 + """ 85 + end 86 + 87 + attr :label, :string, required: true 88 + attr :value, :string, required: true 89 + attr :sub, :string, required: true 90 + attr :tint, :string, required: true 91 + attr :shadow, :string, required: true 92 + 93 + defp stat_card(assigns) do 94 + ~H""" 95 + <div class={["rounded-2xl border-2 border-ink p-5", @tint, @shadow]}> 96 + <div class="text-xs font-bold text-ink/70">{@label}</div> 97 + <div class="mt-2 font-display text-3xl font-bold">{@value}</div> 98 + <div class="mt-1 text-xs text-ink/55">{@sub}</div> 99 + </div> 100 + """ 101 + end 102 + end
+11 -1
lib/annot_at_web/router.ex
··· 18 18 end 19 19 20 20 scope "/", AnnotAtWeb do 21 - pipe_through :browser 21 + pipe_through [:browser, :redirect_if_user_is_authenticated] 22 22 23 23 get "/", PageController, :home 24 24 25 25 get "/login", AuthController, :new 26 26 post "/login", AuthController, :create 27 + end 28 + 29 + scope "/", AnnotAtWeb do 30 + pipe_through :browser 31 + 27 32 get "/auth/callback", AuthController, :callback 28 33 delete "/logout", AuthController, :delete 34 + 35 + live_session :authenticated, 36 + on_mount: [{AnnotAtWeb.UserAuth, :require_authenticated}] do 37 + live "/dashboard", DashboardLive 38 + end 29 39 end 30 40 31 41 scope "/", AnnotAtWeb do
+52 -1
lib/annot_at_web/user_auth.ex
··· 28 28 conn 29 29 |> renew_session() 30 30 |> put_session(:user_id, user.id) 31 - |> redirect(to: ~p"/") 31 + |> redirect(to: ~p"/dashboard") 32 32 end 33 33 34 34 def log_out_user(conn) do ··· 37 37 |> redirect(to: ~p"/") 38 38 end 39 39 40 + def require_authenticatd_user(conn, _opts) do 41 + if conn.assigns.current_scope do 42 + conn 43 + else 44 + conn 45 + |> put_flash(:error, "You must log in to access this page.") 46 + |> redirect(to: ~p"/login") 47 + |> halt() 48 + end 49 + end 50 + 51 + def redirect_if_user_is_authenticated(conn, _opts) do 52 + if conn.assigns.current_scope do 53 + conn 54 + |> redirect(to: ~p"/dashboard") 55 + |> halt() 56 + else 57 + conn 58 + end 59 + end 60 + 61 + def on_mount(:mount_current_scope, _params, session, socket) do 62 + {:cont, mount_current_scope(socket, session)} 63 + end 64 + 65 + def on_mount(:require_authenticated, _params, session, socket) do 66 + socket = mount_current_scope(socket, session) 67 + 68 + if socket.assigns.current_scope do 69 + {:cont, socket} 70 + else 71 + socket = 72 + socket 73 + |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.") 74 + |> Phoenix.LiveView.redirect(to: ~p"/login") 75 + 76 + {:halt, socket} 77 + end 78 + end 79 + 40 80 defp renew_session(conn) do 41 81 conn 42 82 |> configure_session(renew: true) 43 83 |> clear_session() 84 + end 85 + 86 + defp mount_current_scope(socket, session) do 87 + Phoenix.Component.assign_new(socket, :current_scope, fn -> 88 + user = 89 + if user_id = session["user_id"] do 90 + Accounts.get_user(user_id) 91 + end 92 + 93 + Scope.for_user(user) 94 + end) 44 95 end 45 96 end
+2 -13
test/annot_at_web/controllers/auth_controller_test.exs
··· 59 59 assert html_response(conn, 200) =~ "valid handle" 60 60 end 61 61 62 - test "GET /auth/callback logs in and redirects home", %{conn: conn} do 62 + test "GET /auth/callback logs in and redirects dashboard", %{conn: conn} do 63 63 user = create_user() 64 64 expect(Login, :complete_login, fn _ -> {:ok, user} end) 65 65 66 66 conn = get(conn, "/auth/callback?code=c&state=s&iss=i") 67 - assert ~p"/" == redirected_to(conn) 67 + assert ~p"/dashboard" == redirected_to(conn) 68 68 assert user.id == get_session(conn, :user_id) 69 69 end 70 70 ··· 86 86 87 87 assert ~p"/" == redirected_to(conn) 88 88 refute get_session(conn, :user_id) 89 - end 90 - 91 - test "home shows the DID when signed in", %{conn: conn} do 92 - user = create_user() 93 - 94 - conn = 95 - conn 96 - |> init_test_session(%{user_id: user.id}) 97 - |> get(~p"/") 98 - 99 - assert html_response(conn, 200) =~ @did 100 89 end 101 90 end
+48
test/annot_at_web/live/dashboard_live_test.exs
··· 1 + defmodule AnnotAtWeb.DashboardLiveTest do 2 + use AnnotAtWeb.ConnCase, async: true 3 + 4 + import Phoenix.LiveViewTest 5 + 6 + alias AnnotAt.Accounts 7 + 8 + @did "did:plc:ewvi7nxzyoun6zhxrhs64oiz" 9 + 10 + test "renders the greeting for the signed-in user", %{conn: conn} do 11 + user = create_user() 12 + 13 + {:ok, _lv, html} = 14 + conn 15 + |> init_test_session(%{user_id: user.id}) 16 + |> live(~p"/dashboard") 17 + 18 + assert html =~ "Hi Alice" 19 + assert html =~ "Your sites" 20 + end 21 + 22 + test "redirects to login when not authenticated", %{conn: conn} do 23 + assert {:error, {:redirect, %{to: path}}} = live(conn, ~p"/dashboard") 24 + assert ~p"/login" == path 25 + end 26 + 27 + defp create_user do 28 + {:ok, user} = 29 + Accounts.upsert_login( 30 + %{ 31 + did: @did, 32 + handle: "alice.test", 33 + pds_host: "https://pds.example.com", 34 + display_name: "Alice" 35 + }, 36 + %{ 37 + auth_server_issuer: "https://bsky.social", 38 + granted_scopes: "atproto", 39 + access_token: "a", 40 + refresh_token: "r", 41 + dpop_private_jwk: "{}", 42 + expires_at: ~U[2026-01-01 01:00:00Z] 43 + } 44 + ) 45 + 46 + user 47 + end 48 + end