diff --git a/src/atproto/lexicon/mod_listing.rs b/src/atproto/lexicon/mod_listing.rs index 6110011..b184c92 100644 --- a/src/atproto/lexicon/mod_listing.rs +++ b/src/atproto/lexicon/mod_listing.rs @@ -218,6 +218,25 @@ impl ModListing { Ok(Some((rkey, listing))) } + + /// A DID's listings, newest first. + pub async fn find_by_author(db: &SqlitePool, did: &Did) -> Result> { + let rows = sqlx::query( + "SELECT rkey, title, slug, description, details, game, + category, license, tags, media, created_at + FROM mod_listing WHERE author_did = ? ORDER BY created_at DESC", + ) + .bind(did.as_str()) + .fetch_all(db) + .await?; + + rows.into_iter() + .map(|row| { + let rkey: Rkey = row.get("rkey"); + Ok((rkey, row_to_listing(row)?)) + }) + .collect() + } } impl Record for ModListing { diff --git a/src/mods.rs b/src/mods.rs index 4b6080e..e94128d 100644 --- a/src/mods.rs +++ b/src/mods.rs @@ -1,4 +1,5 @@ -//! `/create` to create a mod listing, `/handle/slug` to view or edit one. +//! `/create` to create a mod listing, `/handle` for an actor's profile, +//! `/handle/slug` to view or edit one of their listings. //! //! No `@` sigil or `mods` segment: a handle is a domain name (a dot //! required), so it can never collide with a literal route like `/create`. @@ -24,6 +25,7 @@ use crate::state::AppState; pub fn router() -> Router { Router::new() .route("/create", get(handle_get_create).post(handle_post_create)) + .route("/{handle}", get(handle_get_actor)) .route( "/{handle}/{slug}", get(handle_get_mod) @@ -55,6 +57,38 @@ impl FromRequestParts for Found { } } +async fn handle_get_actor( + Path(handle): Path, + State(state): State, + page: Page, +) -> Result, AppError> { + let handle = Handle::new(&handle).map_err(|e| AppError::BadRequest(e.to_string()))?; + let did = handle + .resolve_did(&state) + .await? + .ok_or(AppError::NotFound)?; + + let listings = ModListing::find_by_author(&state.db, &did) + .await + .map_err(AppError::Internal)?; + + Ok(page + .body(html! { + h1 { (handle) } + @if listings.is_empty() { + p { "No mods yet." } + } @else { + ul { + @for (_, listing) in &listings { + li { a href={ "/" (handle) "/" (listing.slug) } { (listing.title) } } + } + } + } + }) + .title(handle.to_string()) + .render()) +} + async fn handle_get_create(_session: SessionCookie, page: Page) -> Html { page.body(create_form("", None, &CreateFormErrors::default())) .title("Create mod") @@ -391,8 +425,7 @@ async fn handle_post_mod( match existing.save().await { Ok(()) => { - Redirect::to(&format!("/{}/{}", existing.handle, existing.record.slug)) - .into_response() + Redirect::to(&format!("/{}/{}", existing.handle, existing.record.slug)).into_response() } Err(err) => { // Internal errors carry technical detail meant for logs, not users. diff --git a/tests/test_mod_listing.py b/tests/test_mod_listing.py index c81d072..2eda953 100644 --- a/tests/test_mod_listing.py +++ b/tests/test_mod_listing.py @@ -26,6 +26,14 @@ def test_edit_mod_listing_writes_to_pds(actor: Actor): assert record["value"]["title"] == "Test Mod Edited" +def test_actor_page_lists_their_mods(actor: Actor): + actor.login() + actor.create_mod("test-mod") + + actor.page.goto(f"{actor.app_url}/{actor.handle}") + expect(actor.page.get_by_role("link", name="Test Mod")).to_be_visible() + + def test_delete_mod_listing_writes_to_pds(actor: Actor): actor.login() actor.create_mod("test-mod")