steid

@jamesgill /

feat: a global top bar, and pages choose their own width

Considered and rejected an app-shell sidebar (PostHog/GitLab style): Steid's
first audience is an anonymous reader of someone's site, and rail chrome
reads as an admin tool — the exact wrong first impression for a portfolio.
The managed-hosting direction strengthens this, since every customer's
audience is that visitor. Reasoning recorded in plans/ui.md#the-shell.

What ships instead: a slim top bar (wordmark, and Sign in or @handle /
Settings / Sign out — the layout can take cx, which the docs bury), and the
root layout no longer imposes a width. Pages wrap themselves in narrow()
(48rem — profile, forms) or wide() (80rem — tree, blob, log), because a
settings form and a file view do not want the same column; forgetting a
container renders full-bleed, which is visible rather than silently wrong.

Verified in the browser on a scratch instance: visitor and owner top bar,
sign-out, wide tree/blob/log, narrow profile and settings. 448 tests,
clippy clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZwc7URWKVhkAuRTWiDmjA
JamesPatrickGill authored 20 hours agoparent64831abBrowse files5d3dfa50dd9a355b39d1326fbb8f136662b9835e

12 files changed+666 −494

plans/current.md+4 −0View file
@@ -180,6 +180,10 @@ instance, and Steid's own source is pushed to it and browsable there.
180180 `gix`.
181181 - **Fonts are not loaded.** The theme names Geist and IBM Plex Mono; both fall back
182182 today. Topcoat's `font-fontsource` feature handles it.
183+- **The auth pages are unstyled.** `/auth/setup` and `/auth/login` are still bare
184+ milestone-1 HTML, and the styled top bar now sits right above them making it obvious.
185+- **The top bar's wordmark says "steid"**, not the owner's identity — see the open
186+ question at the end of [ui.md](ui.md#the-shell).
183187 - **Light mode is untested.** The palette defines it; nobody has looked at it.
184188 - **No rate limiting** on `/auth/login` or `/auth/setup`.
185189 - **`sweep_expired` is never called**, so expired session rows accumulate. Expiry is
plans/progress.md+25 −1View file
@@ -2,7 +2,7 @@
22
33 ## This attempt (#3, Topcoat)
44
5445 tests. Active milestone in [current.md](current.md).
5+448 tests. Active milestone in [current.md](current.md).
66
77 ### Milestone 0 — Skeleton · done
88
@@ -663,6 +663,30 @@ So a rename feature is genuinely two writes. What makes it more than that is the
663663 consequence: every existing clone URL breaks, and there is no redirect from the old
664664 handle. That is the part to design, not the mechanics.
665665
666+### The shell: top bar and per-page widths · done (2026-09-05)
667+
668+The root layout gained a slim global top bar — wordmark to `/`, and either "Sign in"
669+or `@handle` / Settings / Sign out — and stopped imposing a width. Pages now choose
670+their own column via two components in `layout.rs`: `narrow` (48rem — profile, forms,
671+settings) and `wide` (80rem — tree, blob, log, the repo page). Verified in the
672+browser across visitor and owner states, including sign-out.
673+
674+#### Decisions worth remembering
675+
676+- **A slim bar plus contextual tabs, deliberately not an app shell.** The reasoning
677+ lives in [ui.md](ui.md#the-shell); the short form is that Steid's first audience is
678+ an anonymous reader of someone's site, and GitLab-style rail chrome tells that
679+ visitor they've landed in an admin tool.
680+- **A `#[layout]` may take `cx: &Cx`** alongside `slot` — both recognized by name, no
681+ other parameters. Documented in the vendored `topcoat-router-macro` docs, not
682+ obvious from the README example.
683+- **The layout imposes no width; pages wrap themselves.** Same philosophy as request
684+ helpers being functions: a page that forgets a container renders full-bleed, which
685+ is immediately visible, rather than silently inheriting a wrong one.
686+- **The top bar costs an identity lookup on every page render** (session hash plus a
687+ user row). Fine over local SQLite; worth remembering if page queries are ever
688+ counted.
689+
666690 ---
667691
668692 ## Reference: what attempt #2 proved
plans/ui.md+33 −0View file
@@ -29,6 +29,39 @@ Steid is portfolio-first, not a Gitea clone. The profile page is the product —
2929 are one kind of thing on it, alongside writing and projects. Any layout inherited from
3030 a GitHub-shaped forge needs checking against that before it's copied.
3131
32+## The shell
33+
34+Settled 2026-09-05, when app-shell navigation (PostHog/GitLab-style sidebar) was
35+considered and rejected.
36+
37+**A slim top bar plus contextual tabs, not an app shell.** The reasoning:
38+
39+- Sidebar shells optimise for the logged-in daily user hopping between many top-level
40+ destinations. Steid's first audience is the opposite — an anonymous reader of
41+ someone's site who will never sign in — and rail chrome reads as "self-hosted admin
42+ tool", which is the exact wrong first impression for a portfolio. The managed
43+ offering strengthens this: every paying customer's audience is that visitor.
44+- Steid has four-ish global destinations; a sidebar would be mostly empty rail,
45+ advertising incompleteness the same way the old stacked-section profile did.
46+- A shell-for-owners / site-for-visitors split is the two-design-languages trap again.
47+ One vibe.
48+
49+What ships instead:
50+
51+- **The top bar**: one ~44px row, wordmark on the left → `/`, sign-in state on the
52+ right ("Sign in", or `@handle` / Settings / Sign out). It never grows navigation;
53+ that belongs to the thing being navigated — the profile's tab bar, the repository's.
54+- **Per-page widths.** The layout imposes none; pages wrap themselves in `narrow`
55+ (48rem — prose-shaped: profile, forms, settings, writing) or `wide` (80rem —
56+ code-shaped: tree, blob, log). Both live in `layout.rs`. A page that forgets one
57+ renders full-bleed, which is visible, not silently wrong.
58+- Where a sidebar does earn its place is *within* a wide page — a file tree beside a
59+ blob, a settings-section nav — as local structure, not global chrome.
60+
61+Open: **the wordmark says "steid"**, but on someone's own domain, portfolio-first
62+argues the top-left identity slot belongs to the owner, not to Steid. Undecided;
63+becomes decidable when multi-user lands and "the owner" needs a rule anyway.
64+
3265 ## The profile page
3366
3467 Settled 2026-08-29, after looking at the built page with real data rather than
src/infrastructure/web/browse.rs+40 −35View file
@@ -46,6 +46,7 @@ use crate::{
4646
4747 use super::{
4848 context::{current_actor, memberships, orgs, queries, repos, server_error},
49+ layout::wide,
4950 repo::{clone_url, clone_url_for, repo_for},
5051 };
5152
@@ -222,33 +223,35 @@ async fn browsing(cx: &Cx, rev: Option<RefName>, path: RepoPath) -> Result {
222223 let tags = ref_links(handle, name, &refs.tags, at, &switch);
223224
224225 view! {
225 repo_bar(
226 repo: &repo,
227 rev: at,
228 active: Tab::Files,
229 rev_switcher(current: at, known: known, branches: &branches, tags: &tags)
226+ wide(
227+ repo_bar(
228+ repo: &repo,
229+ rev: at,
230+ active: Tab::Files,
231+ rev_switcher(current: at, known: known, branches: &branches, tags: &tags)
232+ )
233+
234+ match &browsed {
235+ Browsed::Empty => {
236+ clone_url(url: clone.as_str())
237+ empty_repo(url: clone.as_str())
238+ },
239+ Browsed::Directory { rev, path, entries } => directory(
240+ handle: repo.handle.as_str(),
241+ name: repo.name.as_str(),
242+ rev: rev,
243+ path: path,
244+ entries: entries,
245+ ),
246+ Browsed::File { rev, path, file } => blob(
247+ handle: repo.handle.as_str(),
248+ name: repo.name.as_str(),
249+ rev: rev,
250+ path: path,
251+ file: file,
252+ ),
253+ }
230254 )
231
232 match &browsed {
233 Browsed::Empty => {
234 clone_url(url: clone.as_str())
235 empty_repo(url: clone.as_str())
236 },
237 Browsed::Directory { rev, path, entries } => directory(
238 handle: repo.handle.as_str(),
239 name: repo.name.as_str(),
240 rev: rev,
241 path: path,
242 entries: entries,
243 ),
244 Browsed::File { rev, path, file } => blob(
245 handle: repo.handle.as_str(),
246 name: repo.name.as_str(),
247 rev: rev,
248 path: path,
249 file: file,
250 ),
251 }
252255 }
253256 }
254257
@@ -280,16 +283,18 @@ async fn history(cx: &Cx, rev: Option<RefName>) -> Result {
280283 let tags = ref_links(handle, name, &refs.tags, at, &Switch::Log);
281284
282285 view! {
283 repo_bar(
284 repo: &repo,
285 rev: at,
286 active: Tab::Log,
287 // At the default branch the URL names no revision and `repo_log` does not
288 // report the one it resolved, so the switcher opens with nothing marked
289 // current rather than guessing. Noted in `plans/current.md`.
290 rev_switcher(current: at, known: known, branches: &branches, tags: &tags)
286+ wide(
287+ repo_bar(
288+ repo: &repo,
289+ rev: at,
290+ active: Tab::Log,
291+ // At the default branch the URL names no revision and `repo_log` does not
292+ // report the one it resolved, so the switcher opens with nothing marked
293+ // current rather than guessing. Noted in `plans/current.md`.
294+ rev_switcher(current: at, known: known, branches: &branches, tags: &tags)
295+ )
296+ commit_log(commits: &log)
291297 )
292 commit_log(commits: &log)
293298 }
294299 }
295300
src/infrastructure/web/layout.rs+80 −3View file
@@ -1,4 +1,14 @@
1use topcoat::{Result, router::layout, tailwind, view::view};
1+use topcoat::{
2+ Result,
3+ context::Cx,
4+ router::layout,
5+ tailwind,
6+ view::{View, component, view},
7+};
8+
9+use crate::application::Identity;
10+
11+use super::context::identity;
212
313 /// The HTML shell every page renders inside.
414 ///
@@ -8,9 +18,15 @@ use topcoat::{Result, router::layout, tailwind, view::view};
818 ///
919 /// `class="dark"` opts the whole document into the dark palette. Steid is dark-first —
1020 /// developers live there — and light mode is a later toggle rather than the default.
21+///
22+/// The shell imposes no width: prose-shaped pages wrap themselves in [`narrow`] and
23+/// code-shaped ones in [`wide`], because a repository tree and a settings form do not
24+/// want the same column. A page that forgets a container renders full-bleed, which is
25+/// immediately visible — the same reasoning as request helpers being functions.
1126 #[layout("/")]
12async fn root_layout(slot: Result) -> Result {
27+async fn root_layout(cx: &Cx, slot: Result) -> Result {
1328 let content = slot?;
29+ let viewer = identity(cx).await?;
1430
1531 view! {
1632 <!DOCTYPE html>
@@ -23,8 +39,69 @@ async fn root_layout(slot: Result) -> Result {
2339 topcoat::dev::script()
2440 </head>
2541 <body>
26 <main class="mx-auto max-w-3xl px-6 py-10">(content)</main>
42+ top_bar(viewer: &viewer)
43+ <main class="px-6 py-10">(content)</main>
2744 </body>
2845 </html>
2946 }
3047 }
48+
49+/// The one-line global bar: where you are signed in, or the way in.
50+///
51+/// Deliberately not an app shell. Steid's visitors are anonymous readers of someone's
52+/// site, so the chrome stays a slim bar and real navigation lives with the thing being
53+/// navigated — the profile's tab bar, the repository's — rather than in a global rail.
54+///
55+/// Everything on the signed-in side is owner furniture; a visitor sees only "Sign in",
56+/// and the wordmark, which goes to `/` — the owner's profile, by the front-door rule.
57+#[component]
58+async fn top_bar(viewer: &Option<Identity>) -> Result {
59+ view! {
60+ <header class="border-b border-border">
61+ <div class="flex h-11 items-center px-6 text-[13px]">
62+ <a href="/" class="font-mono font-semibold tracking-tight">"steid"</a>
63+ <div class="ml-auto flex items-center gap-5">
64+ match viewer {
65+ Some(me) => {
66+ <a
67+ href=(format!("/{}", me.handle))
68+ class="font-mono text-muted-foreground hover:text-foreground"
69+ >"@" (me.handle.as_str())</a>
70+ <a
71+ href=(format!("/{}/settings", me.handle))
72+ class="text-muted-foreground hover:text-foreground"
73+ >"Settings"</a>
74+ <form method="post" action="/auth/logout">
75+ <button
76+ type="submit"
77+ class="cursor-pointer text-muted-foreground hover:text-foreground"
78+ >"Sign out"</button>
79+ </form>
80+ }
81+ None => {
82+ <a
83+ href="/auth/login"
84+ class="text-muted-foreground hover:text-foreground"
85+ >"Sign in"</a>
86+ }
87+ }
88+ </div>
89+ </div>
90+ </header>
91+ }
92+}
93+
94+/// The centered column for prose-shaped pages: profiles, forms, settings, writing.
95+#[component]
96+pub(super) async fn narrow(#[default] child: View) -> Result {
97+ view! { <div class="mx-auto w-full max-w-3xl">(child)</div> }
98+}
99+
100+/// The full working width for code-shaped pages: trees, blobs, logs.
101+///
102+/// Info density is the point — a file view in a 48rem column wastes the screen the
103+/// reader sized their window to.
104+#[component]
105+pub(super) async fn wide(#[default] child: View) -> Result {
106+ view! { <div class="mx-auto w-full max-w-screen-xl">(child)</div> }
107+}
src/infrastructure/web/pages.rs+8 −3View file
@@ -7,7 +7,10 @@ use topcoat::{
77
88 use crate::application::sole_owner_handle;
99
10use super::context::{claimed, identity, orgs, server_error, users};
10+use super::{
11+ context::{claimed, identity, orgs, server_error, users},
12+ layout::narrow,
13+};
1114
1215 /// The root.
1316 ///
@@ -40,7 +43,9 @@ async fn home(cx: &Cx) -> Result {
4043 }
4144
4245 view! {
43 <h1>"steid"</h1>
44 <p><a href="/auth/login">"Sign in"</a></p>
46+ narrow(
47+ <h1>"steid"</h1>
48+ <p><a href="/auth/login">"Sign in"</a></p>
49+ )
4550 }
4651 }
src/infrastructure/web/profile.rs+59 −54View file
@@ -26,6 +26,7 @@ use crate::{
2626 use super::{
2727 browse::ago,
2828 context::{current_actor, memberships, orgs, repos, server_error},
29+ layout::narrow,
2930 };
3031
3132 /// `{handle}` from the path. The struct name snake-cased is the parameter name.
@@ -257,47 +258,49 @@ async fn profile(cx: &Cx) -> Result {
257258 let handle = profile.handle.to_string();
258259
259260 view! {
260 profile_chrome(identity: &profile, active: Tab::Overview, repo_count: listed.len())
261+ narrow(
262+ profile_chrome(identity: &profile, active: Tab::Overview, repo_count: listed.len())
261263
262 ({
263 match &lead_repo {
264 Some(repo) => view! { lead(handle: handle.as_str(), repo: repo) },
265 None => view! {},
266 }
267 }?)
264+ ({
265+ match &lead_repo {
266+ Some(repo) => view! { lead(handle: handle.as_str(), repo: repo) },
267+ None => view! {},
268+ }
269+ }?)
268270
269 if !rest.is_empty() {
270 <section class="mt-8">
271 <div class="flex items-baseline justify-between">
272 <h2 class="text-[11px] font-medium uppercase tracking-[0.1em] text-muted-foreground/70">
273 "Repositories"
274 </h2>
275 if more > 0 {
276 <a
277 href=(format!("/{handle}/repos"))
278 class="text-xs text-muted-foreground hover:text-foreground"
279 >"All " (listed.len().to_string()) " \u{2192}"</a>
280 }
281 </div>
282 repo_rows(handle: handle.as_str(), repos: &rest)
283 </section>
284 }
271+ if !rest.is_empty() {
272+ <section class="mt-8">
273+ <div class="flex items-baseline justify-between">
274+ <h2 class="text-[11px] font-medium uppercase tracking-[0.1em] text-muted-foreground/70">
275+ "Repositories"
276+ </h2>
277+ if more > 0 {
278+ <a
279+ href=(format!("/{handle}/repos"))
280+ class="text-xs text-muted-foreground hover:text-foreground"
281+ >"All " (listed.len().to_string()) " \u{2192}"</a>
282+ }
283+ </div>
284+ repo_rows(handle: handle.as_str(), repos: &rest)
285+ </section>
286+ }
285287
286 // Nothing to show. A visitor gets silence rather than an empty box advertising
287 // incompleteness; the owner gets the way to fix it.
288 if listed.is_empty() {
289 if profile.viewer_is_owner {
290 <div class="mt-8">
291 <p class="text-sm text-muted-foreground">"No repositories yet."</p>
292 <p class="mt-3">
293 <a
294 href=(format!("/{handle}/repos/new"))
295 class=(button_variants(ButtonVariant::Outline, ButtonSize::Sm))
296 >"New repository"</a>
297 </p>
298 </div>
288+ // Nothing to show. A visitor gets silence rather than an empty box advertising
289+ // incompleteness; the owner gets the way to fix it.
290+ if listed.is_empty() {
291+ if profile.viewer_is_owner {
292+ <div class="mt-8">
293+ <p class="text-sm text-muted-foreground">"No repositories yet."</p>
294+ <p class="mt-3">
295+ <a
296+ href=(format!("/{handle}/repos/new"))
297+ class=(button_variants(ButtonVariant::Outline, ButtonSize::Sm))
298+ >"New repository"</a>
299+ </p>
300+ </div>
301+ }
299302 }
300 }
303+ )
301304 }
302305 }
303306
@@ -327,30 +330,32 @@ async fn repos_index(cx: &Cx) -> Result {
327330 let new_repo = format!("/{handle}/repos/new");
328331
329332 view! {
330 profile_chrome(identity: &identity, active: Tab::Repositories, repo_count: listed.len())
333+ narrow(
334+ profile_chrome(identity: &identity, active: Tab::Repositories, repo_count: listed.len())
331335
332 if listed.is_empty() {
333 <p class="mt-8 text-sm text-muted-foreground">"No repositories yet."</p>
334 if identity.viewer_is_owner {
335 <p class="mt-3">
336 <a
337 href=(new_repo.as_str())
338 class=(button_variants(ButtonVariant::Outline, ButtonSize::Sm))
339 >"New repository"</a>
340 </p>
341 }
342 } else {
343 <div class="mt-6">
336+ if listed.is_empty() {
337+ <p class="mt-8 text-sm text-muted-foreground">"No repositories yet."</p>
344338 if identity.viewer_is_owner {
345 <p class="mb-2 flex justify-end">
339+ <p class="mt-3">
346340 <a
347341 href=(new_repo.as_str())
348342 class=(button_variants(ButtonVariant::Outline, ButtonSize::Sm))
349343 >"New repository"</a>
350344 </p>
351345 }
352 repo_rows(handle: handle.as_str(), repos: &listed)
353 </div>
354 }
346+ } else {
347+ <div class="mt-6">
348+ if identity.viewer_is_owner {
349+ <p class="mb-2 flex justify-end">
350+ <a
351+ href=(new_repo.as_str())
352+ class=(button_variants(ButtonVariant::Outline, ButtonSize::Sm))
353+ >"New repository"</a>
354+ </p>
355+ }
356+ repo_rows(handle: handle.as_str(), repos: &listed)
357+ </div>
358+ }
359+ )
355360 }
356361 }
src/infrastructure/web/repo.rs+141 −136View file
@@ -38,6 +38,7 @@ use super::{
3838 context::{
3939 current_actor, location, memberships, orgs, public_origin, repos, server_error, storage,
4040 },
41+ layout::{narrow, wide},
4142 markdown,
4243 profile::profile_for,
4344 };
@@ -178,82 +179,84 @@ async fn repo_page(cx: &Cx) -> Result {
178179 let browsed = browsed_at(cx, &repo, None, &RepoPath::root()).await?;
179180
180181 view! {
181 <header class="mb-8">
182 <p class="font-mono text-sm text-muted-foreground">
183 <a href=(format!("/{}", repo.handle)) class="hover:text-foreground">
184 "@" (repo.handle.as_str())
185 </a>
186 " / "
187 </p>
188 <div class="mt-1 flex items-center gap-3">
189 <h1 class="text-2xl font-semibold tracking-tight">(repo.name.as_str())</h1>
190 if !repo.visibility.is_public() {
191 badge(variant: BadgeVariant::Outline, "Private")
192 }
193 // Only the owner can change a repository, so only the owner is offered
194 // the way in. The settings page enforces this again — this is the link
195 // not being a dead end, not the authorization.
196 if repo.viewer_is_owner {
197 <a
198 href=(format!("/{}/repos/{}/settings", repo.handle, repo.name))
199 class=(format!(
200 "ml-auto {}",
201 button_variants(ButtonVariant::Outline, ButtonSize::Sm)
202 ))
203 >"Settings"</a>
204 }
205 </div>
206 ({
207 match &repo.description {
208 Some(description) => view! {
209 <p class="mt-3 text-sm leading-relaxed">(description)</p>
210 },
211 None => view! {},
212 }
213 }?)
214 </header>
215
216 clone_url(url: clone.as_str())
217
218 match &browsed {
219 Browsed::Empty => empty_repo(url: clone.as_str()),
220 Browsed::Directory { rev, path, entries } => {
221 <div class="mt-8 mb-3 flex items-center justify-between text-sm">
222 <span class="inline-flex items-center gap-1.5 font-mono text-xs text-muted-foreground">
223 (rev.as_str())
224 </span>
225 <a
226 href=(format!("/{}/repos/{}/log", repo.handle, repo.name))
227 class="text-muted-foreground hover:text-foreground"
228 >"Commits"</a>
182+ wide(
183+ <header class="mb-8">
184+ <p class="font-mono text-sm text-muted-foreground">
185+ <a href=(format!("/{}", repo.handle)) class="hover:text-foreground">
186+ "@" (repo.handle.as_str())
187+ </a>
188+ " / "
189+ </p>
190+ <div class="mt-1 flex items-center gap-3">
191+ <h1 class="text-2xl font-semibold tracking-tight">(repo.name.as_str())</h1>
192+ if !repo.visibility.is_public() {
193+ badge(variant: BadgeVariant::Outline, "Private")
194+ }
195+ // Only the owner can change a repository, so only the owner is offered
196+ // the way in. The settings page enforces this again — this is the link
197+ // not being a dead end, not the authorization.
198+ if repo.viewer_is_owner {
199+ <a
200+ href=(format!("/{}/repos/{}/settings", repo.handle, repo.name))
201+ class=(format!(
202+ "ml-auto {}",
203+ button_variants(ButtonVariant::Outline, ButtonSize::Sm)
204+ ))
205+ >"Settings"</a>
206+ }
229207 </div>
230 directory(
231 handle: repo.handle.as_str(),
232 name: repo.name.as_str(),
233 rev: rev,
234 path: path,
235 entries: entries,
236 )
237
238 // No README means nothing here at all — an empty panel saying a
239 // repository has no README is worse than the silence.
240 match readme_of(entries) {
241 Some(entry) => readme_card(repo: &repo, rev: rev, entry: entry),
242 None => "",
243 }
244 },
245 // The root of a revision is always a directory, so this is unreachable in
246 // practice — rendered rather than errored so it can never be a 500.
247 Browsed::File { rev, path, file } => <div class="mt-8">
248 blob(
249 handle: repo.handle.as_str(),
250 name: repo.name.as_str(),
251 rev: rev,
252 path: path,
253 file: file,
254 )
255 </div>,
256 }
208+ ({
209+ match &repo.description {
210+ Some(description) => view! {
211+ <p class="mt-3 text-sm leading-relaxed">(description)</p>
212+ },
213+ None => view! {},
214+ }
215+ }?)
216+ </header>
217+
218+ clone_url(url: clone.as_str())
219+
220+ match &browsed {
221+ Browsed::Empty => empty_repo(url: clone.as_str()),
222+ Browsed::Directory { rev, path, entries } => {
223+ <div class="mt-8 mb-3 flex items-center justify-between text-sm">
224+ <span class="inline-flex items-center gap-1.5 font-mono text-xs text-muted-foreground">
225+ (rev.as_str())
226+ </span>
227+ <a
228+ href=(format!("/{}/repos/{}/log", repo.handle, repo.name))
229+ class="text-muted-foreground hover:text-foreground"
230+ >"Commits"</a>
231+ </div>
232+ directory(
233+ handle: repo.handle.as_str(),
234+ name: repo.name.as_str(),
235+ rev: rev,
236+ path: path,
237+ entries: entries,
238+ )
239+
240+ // No README means nothing here at all — an empty panel saying a
241+ // repository has no README is worse than the silence.
242+ match readme_of(entries) {
243+ Some(entry) => readme_card(repo: &repo, rev: rev, entry: entry),
244+ None => "",
245+ }
246+ },
247+ // The root of a revision is always a directory, so this is unreachable in
248+ // practice — rendered rather than errored so it can never be a 500.
249+ Browsed::File { rev, path, file } => <div class="mt-8">
250+ blob(
251+ handle: repo.handle.as_str(),
252+ name: repo.name.as_str(),
253+ rev: rev,
254+ path: path,
255+ file: file,
256+ )
257+ </div>,
258+ }
259+ )
257260 }
258261 }
259262
@@ -397,71 +400,73 @@ async fn new_repo_form(
397400 error: &str,
398401 ) -> Result {
399402 view! {
400 <h1 class="text-xl font-semibold tracking-tight">"New repository"</h1>
401 <p class="mt-1 font-mono text-sm text-muted-foreground">"@" (handle)</p>
402
403 if !error.is_empty() {
404 <div class="mt-6">
405 flash(kind: FlashKind::Error, (error))
406 </div>
407 }
403+ narrow(
404+ <h1 class="text-xl font-semibold tracking-tight">"New repository"</h1>
405+ <p class="mt-1 font-mono text-sm text-muted-foreground">"@" (handle)</p>
408406
409 <form method="post" action=(format!("/{handle}/repos/new")) class="mt-6 space-y-5">
410 <div class="space-y-2">
411 label(attrs: attributes! { for="name" }, "Name")
412 input(attrs: attributes! {
413 id="name"
414 name="name"
415 type="text"
416 value=(name)
417 placeholder="my-project"
418 required=(true)
419 maxlength=(RepoName::MAX_LEN.to_string())
420 autofocus=(true)
421 })
422 <p class="text-xs text-muted-foreground">
423 "Letters, digits, hyphens, underscores and dots. Lowercased."
424 </p>
425 </div>
407+ if !error.is_empty() {
408+ <div class="mt-6">
409+ flash(kind: FlashKind::Error, (error))
410+ </div>
411+ }
412+
413+ <form method="post" action=(format!("/{handle}/repos/new")) class="mt-6 space-y-5">
414+ <div class="space-y-2">
415+ label(attrs: attributes! { for="name" }, "Name")
416+ input(attrs: attributes! {
417+ id="name"
418+ name="name"
419+ type="text"
420+ value=(name)
421+ placeholder="my-project"
422+ required=(true)
423+ maxlength=(RepoName::MAX_LEN.to_string())
424+ autofocus=(true)
425+ })
426+ <p class="text-xs text-muted-foreground">
427+ "Letters, digits, hyphens, underscores and dots. Lowercased."
428+ </p>
429+ </div>
426430
427 <div class="space-y-2">
428 label(attrs: attributes! { for="description" }, "Description")
429 textarea(
430 attrs: attributes! {
431 id="description"
432 name="description"
433 rows="2"
434 maxlength=(Repository::MAX_DESCRIPTION_LEN.to_string())
435 placeholder="A sentence for your profile."
436 },
437 (description)
438 )
439 <p class="text-xs text-muted-foreground">
440 "Optional. At most "
441 (Repository::MAX_DESCRIPTION_LEN.to_string()) " characters."
442 </p>
443 </div>
431+ <div class="space-y-2">
432+ label(attrs: attributes! { for="description" }, "Description")
433+ textarea(
434+ attrs: attributes! {
435+ id="description"
436+ name="description"
437+ rows="2"
438+ maxlength=(Repository::MAX_DESCRIPTION_LEN.to_string())
439+ placeholder="A sentence for your profile."
440+ },
441+ (description)
442+ )
443+ <p class="text-xs text-muted-foreground">
444+ "Optional. At most "
445+ (Repository::MAX_DESCRIPTION_LEN.to_string()) " characters."
446+ </p>
447+ </div>
444448
445 <div class="space-y-2">
446 label(attrs: attributes! { for="visibility" }, "Visibility")
447 select(
448 attrs: attributes! { id="visibility" name="visibility" },
449 <option value="public" selected=(visibility.is_public())>"Public"</option>
450 <option value="private" selected=(!visibility.is_public())>"Private"</option>
451 )
452 <p class="text-xs text-muted-foreground">
453 "Public repositories appear on your profile to anyone."
454 </p>
455 </div>
449+ <div class="space-y-2">
450+ label(attrs: attributes! { for="visibility" }, "Visibility")
451+ select(
452+ attrs: attributes! { id="visibility" name="visibility" },
453+ <option value="public" selected=(visibility.is_public())>"Public"</option>
454+ <option value="private" selected=(!visibility.is_public())>"Private"</option>
455+ )
456+ <p class="text-xs text-muted-foreground">
457+ "Public repositories appear on your profile to anyone."
458+ </p>
459+ </div>
456460
457 <div class="flex items-center gap-3">
458 button(attrs: attributes! { type="submit" }, "Create repository")
459 <a
460 href=(format!("/{handle}"))
461 class="text-sm text-muted-foreground hover:text-foreground"
462 >"Cancel"</a>
463 </div>
464 </form>
461+ <div class="flex items-center gap-3">
462+ button(attrs: attributes! { type="submit" }, "Create repository")
463+ <a
464+ href=(format!("/{handle}"))
465+ class="text-sm text-muted-foreground hover:text-foreground"
466+ >"Cancel"</a>
467+ </div>
468+ </form>
469+ )
465470 }
466471 }
467472
src/infrastructure/web/repo_settings.rs+109 −106View file
@@ -37,6 +37,7 @@ use crate::{
3737
3838 use super::{
3939 context::{current_actor, location, memberships, orgs, repos, server_error, storage},
40+ layout::narrow,
4041 repo::repo_for,
4142 };
4243
@@ -261,121 +262,123 @@ async fn settings_view(
261262 } = fields;
262263
263264 view! {
264 <h1 class="text-xl font-semibold tracking-tight">"Repository settings"</h1>
265 <p class="mt-1 font-mono text-sm text-muted-foreground">
266 "@" (handle) " / " (name)
267 </p>
268
269 if saved {
270 <div class="mt-6">
271 flash(kind: FlashKind::Success, "Repository updated.")
272 </div>
273 }
265+ narrow(
266+ <h1 class="text-xl font-semibold tracking-tight">"Repository settings"</h1>
267+ <p class="mt-1 font-mono text-sm text-muted-foreground">
268+ "@" (handle) " / " (name)
269+ </p>
274270
275 if !error.is_empty() {
276 <div class="mt-6">
277 flash(kind: FlashKind::Error, (error))
278 </div>
279 }
271+ if saved {
272+ <div class="mt-6">
273+ flash(kind: FlashKind::Success, "Repository updated.")
274+ </div>
275+ }
280276
281 <form
282 method="post"
283 action=(format!("/{handle}/repos/{name}/settings"))
284 class="mt-6 space-y-5"
285 >
286 <div class="space-y-2">
287 label(attrs: attributes! { for="description" }, "Description")
288 textarea(
289 attrs: attributes! {
290 id="description"
291 name="description"
292 rows="2"
293 maxlength=(Repository::MAX_DESCRIPTION_LEN.to_string())
294 placeholder="A sentence for your profile."
295 },
296 (description)
297 )
298 <p class="text-xs text-muted-foreground">
299 "Optional. At most "
300 (Repository::MAX_DESCRIPTION_LEN.to_string()) " characters."
301 </p>
302 </div>
303
304 <div class="space-y-2">
305 label(attrs: attributes! { for="visibility" }, "Visibility")
306 select(
307 attrs: attributes! { id="visibility" name="visibility" },
308 <option value="public" selected=(visibility.is_public())>"Public"</option>
309 <option value="private" selected=(!visibility.is_public())>"Private"</option>
310 )
311 <p class="text-xs text-muted-foreground">
312 "A private repository is hidden from your profile and needs a token "
313 "to clone."
314 </p>
315 </div>
316
317 <div class="space-y-2">
318 <div class="flex items-start gap-2">
319 <input
320 id="pinned"
321 name="pinned"
322 type="checkbox"
323 value="on"
324 checked=(pinned)
325 class="mt-0.5"
326 />
327 label(attrs: attributes! { for="pinned" }, "Lead my profile with this")
277+ if !error.is_empty() {
278+ <div class="mt-6">
279+ flash(kind: FlashKind::Error, (error))
328280 </div>
329 <p class="text-xs text-muted-foreground">
330 "One repository at a time. Choosing this one lets go of whichever "
331 "was chosen before."
332 </p>
333 </div>
334
335 <div class="flex items-center gap-3">
336 button(attrs: attributes! { type="submit" }, "Save")
337 <a
338 href=(format!("/{handle}/repos/{name}"))
339 class="text-sm text-muted-foreground hover:text-foreground"
340 >"Back to repository"</a>
341 </div>
342 </form>
343
344 <section class="mt-10 rounded-lg border border-destructive/30 p-4">
345 <h2 class="text-xs font-medium uppercase tracking-wider text-destructive">
346 "Delete this repository"
347 </h2>
348 <p class="mt-2 text-sm text-muted-foreground">
349 "This cannot be undone. The commits, branches and tags go with it, and "
350 "anyone with a clone keeps their copy while this instance keeps nothing."
351 </p>
281+ }
352282
353283 <form
354284 method="post"
355 action=(format!("/{handle}/repos/{name}/settings/delete"))
356 class="mt-4 space-y-2"
285+ action=(format!("/{handle}/repos/{name}/settings"))
286+ class="mt-6 space-y-5"
357287 >
358 label(
359 attrs: attributes! { for="confirm" },
360 "Type " (name) " to confirm"
361 )
362 input(attrs: attributes! {
363 id="confirm"
364 name="confirm"
365 type="text"
366 value=""
367 placeholder=(name)
368 required=(true)
369 autocomplete="off"
370 })
371 <div class="pt-1">
372 button(
373 attrs: attributes! { type="submit" },
374 variant: ButtonVariant::Destructive,
375 "Delete repository"
288+ <div class="space-y-2">
289+ label(attrs: attributes! { for="description" }, "Description")
290+ textarea(
291+ attrs: attributes! {
292+ id="description"
293+ name="description"
294+ rows="2"
295+ maxlength=(Repository::MAX_DESCRIPTION_LEN.to_string())
296+ placeholder="A sentence for your profile."
297+ },
298+ (description)
299+ )
300+ <p class="text-xs text-muted-foreground">
301+ "Optional. At most "
302+ (Repository::MAX_DESCRIPTION_LEN.to_string()) " characters."
303+ </p>
304+ </div>
305+
306+ <div class="space-y-2">
307+ label(attrs: attributes! { for="visibility" }, "Visibility")
308+ select(
309+ attrs: attributes! { id="visibility" name="visibility" },
310+ <option value="public" selected=(visibility.is_public())>"Public"</option>
311+ <option value="private" selected=(!visibility.is_public())>"Private"</option>
376312 )
313+ <p class="text-xs text-muted-foreground">
314+ "A private repository is hidden from your profile and needs a token "
315+ "to clone."
316+ </p>
317+ </div>
318+
319+ <div class="space-y-2">
320+ <div class="flex items-start gap-2">
321+ <input
322+ id="pinned"
323+ name="pinned"
324+ type="checkbox"
325+ value="on"
326+ checked=(pinned)
327+ class="mt-0.5"
328+ />
329+ label(attrs: attributes! { for="pinned" }, "Lead my profile with this")
330+ </div>
331+ <p class="text-xs text-muted-foreground">
332+ "One repository at a time. Choosing this one lets go of whichever "
333+ "was chosen before."
334+ </p>
335+ </div>
336+
337+ <div class="flex items-center gap-3">
338+ button(attrs: attributes! { type="submit" }, "Save")
339+ <a
340+ href=(format!("/{handle}/repos/{name}"))
341+ class="text-sm text-muted-foreground hover:text-foreground"
342+ >"Back to repository"</a>
377343 </div>
378344 </form>
379 </section>
345+
346+ <section class="mt-10 rounded-lg border border-destructive/30 p-4">
347+ <h2 class="text-xs font-medium uppercase tracking-wider text-destructive">
348+ "Delete this repository"
349+ </h2>
350+ <p class="mt-2 text-sm text-muted-foreground">
351+ "This cannot be undone. The commits, branches and tags go with it, and "
352+ "anyone with a clone keeps their copy while this instance keeps nothing."
353+ </p>
354+
355+ <form
356+ method="post"
357+ action=(format!("/{handle}/repos/{name}/settings/delete"))
358+ class="mt-4 space-y-2"
359+ >
360+ label(
361+ attrs: attributes! { for="confirm" },
362+ "Type " (name) " to confirm"
363+ )
364+ input(attrs: attributes! {
365+ id="confirm"
366+ name="confirm"
367+ type="text"
368+ value=""
369+ placeholder=(name)
370+ required=(true)
371+ autocomplete="off"
372+ })
373+ <div class="pt-1">
374+ button(
375+ attrs: attributes! { type="submit" },
376+ variant: ButtonVariant::Destructive,
377+ "Delete repository"
378+ )
379+ </div>
380+ </form>
381+ </section>
382+ )
380383 }
381384 }
src/infrastructure/web/settings.rs+71 −68View file
@@ -25,6 +25,7 @@ use crate::{
2525
2626 use super::{
2727 context::{current_actor, location, memberships, orgs, server_error},
28+ layout::narrow,
2829 profile::profile_for,
2930 };
3031
@@ -133,75 +134,77 @@ async fn settings_form(
133134 error: &str,
134135 ) -> Result {
135136 view! {
136 <h1 class="text-xl font-semibold tracking-tight">"Profile settings"</h1>
137 <p class="mt-1 font-mono text-sm text-muted-foreground">"@" (handle)</p>
138
139 if saved {
140 <div class="mt-6">
141 flash(kind: FlashKind::Success, "Profile updated.")
142 </div>
143 }
144
145 if !error.is_empty() {
146 <div class="mt-6">
147 flash(kind: FlashKind::Error, (error))
148 </div>
149 }
150
151 <form method="post" action=(format!("/{handle}/settings")) class="mt-6 space-y-5">
152 <div class="space-y-2">
153 label(attrs: attributes! { for="display_name" }, "Display name")
154 input(attrs: attributes! {
155 id="display_name"
156 name="display_name"
157 type="text"
158 value=(display_name)
159 placeholder=(handle)
160 })
161 <p class="text-xs text-muted-foreground">
162 "Shown instead of your handle. Leave blank to use @" (handle) "."
137+ narrow(
138+ <h1 class="text-xl font-semibold tracking-tight">"Profile settings"</h1>
139+ <p class="mt-1 font-mono text-sm text-muted-foreground">"@" (handle)</p>
140+
141+ if saved {
142+ <div class="mt-6">
143+ flash(kind: FlashKind::Success, "Profile updated.")
144+ </div>
145+ }
146+
147+ if !error.is_empty() {
148+ <div class="mt-6">
149+ flash(kind: FlashKind::Error, (error))
150+ </div>
151+ }
152+
153+ <form method="post" action=(format!("/{handle}/settings")) class="mt-6 space-y-5">
154+ <div class="space-y-2">
155+ label(attrs: attributes! { for="display_name" }, "Display name")
156+ input(attrs: attributes! {
157+ id="display_name"
158+ name="display_name"
159+ type="text"
160+ value=(display_name)
161+ placeholder=(handle)
162+ })
163+ <p class="text-xs text-muted-foreground">
164+ "Shown instead of your handle. Leave blank to use @" (handle) "."
165+ </p>
166+ </div>
167+
168+ <div class="space-y-2">
169+ label(attrs: attributes! { for="bio" }, "Bio")
170+ textarea(
171+ attrs: attributes! {
172+ id="bio"
173+ name="bio"
174+ rows="4"
175+ maxlength=(Organization::MAX_BIO_LEN.to_string())
176+ },
177+ (bio)
178+ )
179+ <p class="text-xs text-muted-foreground">
180+ "At most " (Organization::MAX_BIO_LEN.to_string()) " characters."
181+ </p>
182+ </div>
183+
184+ <div class="flex items-center gap-3">
185+ button(attrs: attributes! { type="submit" }, "Save")
186+ <a
187+ href=(format!("/{handle}"))
188+ class="text-sm text-muted-foreground hover:text-foreground"
189+ >"Back to profile"</a>
190+ </div>
191+ </form>
192+
193+ <section class="mt-10 border-t border-border pt-6">
194+ <h2 class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
195+ "Access tokens"
196+ </h2>
197+ <p class="mt-2 text-sm text-muted-foreground">
198+ "Tokens are how git authenticates when you push or clone a private "
199+ "repository over HTTPS."
163200 </p>
164 </div>
165
166 <div class="space-y-2">
167 label(attrs: attributes! { for="bio" }, "Bio")
168 textarea(
169 attrs: attributes! {
170 id="bio"
171 name="bio"
172 rows="4"
173 maxlength=(Organization::MAX_BIO_LEN.to_string())
174 },
175 (bio)
176 )
177 <p class="text-xs text-muted-foreground">
178 "At most " (Organization::MAX_BIO_LEN.to_string()) " characters."
201+ <p class="mt-3">
202+ <a
203+ href=(format!("/{handle}/settings/tokens"))
204+ class="text-sm font-medium hover:underline"
205+ >"Manage tokens →"</a>
179206 </p>
180 </div>
181
182 <div class="flex items-center gap-3">
183 button(attrs: attributes! { type="submit" }, "Save")
184 <a
185 href=(format!("/{handle}"))
186 class="text-sm text-muted-foreground hover:text-foreground"
187 >"Back to profile"</a>
188 </div>
189 </form>
190
191 <section class="mt-10 border-t border-border pt-6">
192 <h2 class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
193 "Access tokens"
194 </h2>
195 <p class="mt-2 text-sm text-muted-foreground">
196 "Tokens are how git authenticates when you push or clone a private "
197 "repository over HTTPS."
198 </p>
199 <p class="mt-3">
200 <a
201 href=(format!("/{handle}/settings/tokens"))
202 class="text-sm font-medium hover:underline"
203 >"Manage tokens →"</a>
204 </p>
205 </section>
207+ </section>
208+ )
206209 }
207210 }
src/infrastructure/web/setup.rs+14 −9View file
@@ -25,6 +25,7 @@ use crate::{
2525
2626 use super::{
2727 context::{claimed, hex, pool, setup_token},
28+ layout::narrow,
2829 rate_limit::throttled,
2930 };
3031
@@ -49,15 +50,17 @@ async fn setup_page(cx: &Cx) -> Result {
4950 }
5051
5152 view! {
52 <h1>"Claim this instance"</h1>
53 <p>"The setup token was printed to the server log at startup."</p>
54 <form method="post" action="/auth/setup">
55 <p><label>"Setup token " <input type="text" name="token" required="true" /></label></p>
56 <p><label>"Handle " <input type="text" name="handle" required="true" /></label></p>
57 <p><label>"Email " <input type="email" name="email" required="true" /></label></p>
58 <p><label>"Password " <input type="password" name="password" required="true" /></label></p>
59 <button type="submit">"Claim"</button>
60 </form>
53+ narrow(
54+ <h1>"Claim this instance"</h1>
55+ <p>"The setup token was printed to the server log at startup."</p>
56+ <form method="post" action="/auth/setup">
57+ <p><label>"Setup token " <input type="text" name="token" required="true" /></label></p>
58+ <p><label>"Handle " <input type="text" name="handle" required="true" /></label></p>
59+ <p><label>"Email " <input type="email" name="email" required="true" /></label></p>
60+ <p><label>"Password " <input type="password" name="password" required="true" /></label></p>
61+ <button type="submit">"Claim"</button>
62+ </form>
63+ )
6164 }
6265 }
6366
@@ -113,12 +116,14 @@ async fn login_page(cx: &Cx) -> Result {
113116 }
114117
115118 view! {
119+ narrow(
116120 <h1>"Sign in"</h1>
117121 <form method="post" action="/auth/login">
118122 <p><label>"Email " <input type="email" name="email" required="true" /></label></p>
119123 <p><label>"Password " <input type="password" name="password" required="true" /></label></p>
120124 <button type="submit">"Sign in"</button>
121125 </form>
126+ )
122127 }
123128 }
124129
src/infrastructure/web/token.rs+82 −79View file
@@ -27,6 +27,7 @@ use crate::{
2727
2828 use super::{
2929 context::{current_actor, location, server_error, tokens},
30+ layout::narrow,
3031 profile::profile_for,
3132 };
3233
@@ -145,86 +146,88 @@ async fn tokens_view(
145146 name: &str,
146147 ) -> Result {
147148 view! {
148 <h1 class="text-xl font-semibold tracking-tight">"Access tokens"</h1>
149 <p class="mt-1 text-sm text-muted-foreground">
150 "Use a token as the password when git asks. Your username can be anything."
151 </p>
152
153 if !issued.is_empty() {
154 <div class="mt-6 space-y-2">
155 flash(
156 kind: FlashKind::Success,
157 "Copy this now — it is not shown again."
158 )
159 <pre class="overflow-x-auto rounded-lg border border-border bg-muted px-4 py-3 font-mono text-sm">(issued)</pre>
160 </div>
161 }
162
163 if !error.is_empty() {
164 <div class="mt-6">
165 flash(kind: FlashKind::Error, (error))
166 </div>
167 }
149+ narrow(
150+ <h1 class="text-xl font-semibold tracking-tight">"Access tokens"</h1>
151+ <p class="mt-1 text-sm text-muted-foreground">
152+ "Use a token as the password when git asks. Your username can be anything."
153+ </p>
154+
155+ if !issued.is_empty() {
156+ <div class="mt-6 space-y-2">
157+ flash(
158+ kind: FlashKind::Success,
159+ "Copy this now — it is not shown again."
160+ )
161+ <pre class="overflow-x-auto rounded-lg border border-border bg-muted px-4 py-3 font-mono text-sm">(issued)</pre>
162+ </div>
163+ }
168164
169 <form method="post" action=(format!("/{handle}/settings/tokens")) class="mt-6 space-y-5">
170 <div class="space-y-2">
171 label(attrs: attributes! { for="name" }, "Token name")
172 input(attrs: attributes! {
173 id="name"
174 name="name"
175 type="text"
176 value=(name)
177 placeholder="laptop"
178 required=(true)
179 })
180 <p class="text-xs text-muted-foreground">
181 "So you can tell which one to revoke later."
182 </p>
183 </div>
184
185 button(attrs: attributes! { type="submit" }, "Create token")
186 </form>
187
188 <section class="mt-10">
189 <h2 class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
190 "Your tokens"
191 </h2>
192
193 if tokens.is_empty() {
194 <p class="mt-2 rounded-lg border border-border px-4 py-6 text-center text-sm text-muted-foreground">
195 "No tokens yet."
196 </p>
197 } else {
198 <ul class="mt-2 divide-y divide-border rounded-lg border border-border">
199 for token in tokens {
200 <li class="flex items-center justify-between gap-4 px-4 py-3">
201 <div class="flex items-baseline gap-2">
202 <span class="font-medium">(token.name.as_str())</span>
203 badge(
204 variant: BadgeVariant::Outline,
205 "steid_pat_" (token.prefix.as_str()) "…"
206 )
207 </div>
208 <form
209 method="post"
210 action=(format!("/{handle}/settings/tokens/{}/revoke", token.id))
211 >
212 button(
213 attrs: attributes! { type="submit" },
214 "Revoke"
215 )
216 </form>
217 </li>
218 }
219 </ul>
165+ if !error.is_empty() {
166+ <div class="mt-6">
167+ flash(kind: FlashKind::Error, (error))
168+ </div>
220169 }
221 </section>
222
223 <p class="mt-8">
224 <a
225 href=(format!("/{handle}/settings"))
226 class="text-sm text-muted-foreground hover:text-foreground"
227 >"Back to settings"</a>
228 </p>
170+
171+ <form method="post" action=(format!("/{handle}/settings/tokens")) class="mt-6 space-y-5">
172+ <div class="space-y-2">
173+ label(attrs: attributes! { for="name" }, "Token name")
174+ input(attrs: attributes! {
175+ id="name"
176+ name="name"
177+ type="text"
178+ value=(name)
179+ placeholder="laptop"
180+ required=(true)
181+ })
182+ <p class="text-xs text-muted-foreground">
183+ "So you can tell which one to revoke later."
184+ </p>
185+ </div>
186+
187+ button(attrs: attributes! { type="submit" }, "Create token")
188+ </form>
189+
190+ <section class="mt-10">
191+ <h2 class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
192+ "Your tokens"
193+ </h2>
194+
195+ if tokens.is_empty() {
196+ <p class="mt-2 rounded-lg border border-border px-4 py-6 text-center text-sm text-muted-foreground">
197+ "No tokens yet."
198+ </p>
199+ } else {
200+ <ul class="mt-2 divide-y divide-border rounded-lg border border-border">
201+ for token in tokens {
202+ <li class="flex items-center justify-between gap-4 px-4 py-3">
203+ <div class="flex items-baseline gap-2">
204+ <span class="font-medium">(token.name.as_str())</span>
205+ badge(
206+ variant: BadgeVariant::Outline,
207+ "steid_pat_" (token.prefix.as_str()) "…"
208+ )
209+ </div>
210+ <form
211+ method="post"
212+ action=(format!("/{handle}/settings/tokens/{}/revoke", token.id))
213+ >
214+ button(
215+ attrs: attributes! { type="submit" },
216+ "Revoke"
217+ )
218+ </form>
219+ </li>
220+ }
221+ </ul>
222+ }
223+ </section>
224+
225+ <p class="mt-8">
226+ <a
227+ href=(format!("/{handle}/settings"))
228+ class="text-sm text-muted-foreground hover:text-foreground"
229+ >"Back to settings"</a>
230+ </p>
231+ )
229232 }
230233 }