diff --git a/assets/components.css b/assets/components.css index 18c5610..7492fe1 100644 --- a/assets/components.css +++ b/assets/components.css @@ -1035,8 +1035,12 @@ body:has(.home-intro) .site-tab[aria-current="page"] { grid-area: media; } + /* What the mod is and how to get it at the top, who made it at the + bottom, whatever the card's height. */ & > .card { + display: grid; grid-area: card; + grid-template-rows: 1fr auto; } & > .mod-details { @@ -1088,6 +1092,85 @@ body:has(.home-intro) .site-tab[aria-current="page"] { font-size: var(--text-sm); } +.mod-summary { + align-self: start; +} + +.mod-credits-section { + align-self: end; +} + +.mod-credits-heading { + margin-block: var(--space-4) 0; + color: var(--example); + font-size: var(--text-sm); +} + +.mod-credits { + display: grid; + gap: var(--space-2); + margin-block: var(--space-2) 0; + padding: 0; + list-style: none; + + & .mod-credit { + display: flex; + align-items: center; + gap: var(--space-2); + color: var(--grey); + } + + & img { + inline-size: 1.75rem; + block-size: 1.75rem; + border-radius: 50%; + object-fit: cover; + } + + /* The name and handle on one line, the role on its own beneath. */ + & .mod-credit-names { + display: flex; + flex-direction: column; + min-inline-size: 0; + overflow-wrap: anywhere; + } + + /* The name and the handle stay on one line, the handle giving way first. */ + & .mod-credit-who { + display: flex; + align-items: baseline; + gap: 0 var(--space-2); + min-inline-size: 0; + max-inline-size: 100%; + + & > * { + min-inline-size: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + & .mod-credit-name { + flex-shrink: 0; + max-inline-size: 100%; + } + } + + & a.mod-credit-name { + color: inherit; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + & :is(.mod-credit-handle, .mod-credit-role) { + color: var(--example); + font-size: var(--text-sm); + } +} + .mod-image-placeholder { width: 100%; aspect-ratio: 4 / 3; @@ -2340,6 +2423,32 @@ download-fields { white-space: nowrap; } + /* Overlapping, so no one's avatar stands for the rest. */ + & .byline-avatars { + display: flex; + flex: none; + + & .byline-avatar { + box-shadow: 0 0 0 2px var(--paper); + } + + & .byline-avatar + .byline-avatar { + margin-inline-start: -0.4rem; + } + } + + /* Everyone credited, as many as fit in two lines. */ + & .byline-names { + display: -webkit-box; + min-inline-size: 0; + overflow: hidden; + color: var(--grey); + overflow-wrap: anywhere; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + } + & .byline-stars { display: inline-flex; align-items: center; diff --git a/assets/components/credits_input.js b/assets/components/credits_input.js new file mode 100644 index 0000000..4c58052 --- /dev/null +++ b/assets/components/credits_input.js @@ -0,0 +1,74 @@ +class CreditsInput extends HTMLElement { + connectedCallback() { + requestAnimationFrame(() => this.#setup()); + } + + #setup() { + const list = this; + const blank = this.querySelector("template").content.firstElementChild; + const add = this.querySelector(".add-person"); + const field = this.querySelector("input[type=hidden]"); + + function addRow() { + const row = blank.cloneNode(true); + list.insertBefore(row, add); + row.querySelector(".person input").focus(); + } + + add.addEventListener("click", addRow); + + list.addEventListener("click", (event) => { + event.target.closest(".remove")?.closest(".item")?.remove(); + }); + + // Enter here would otherwise save the whole form: it moves on to the + // next row instead, and makes one after the last. + list.addEventListener("keydown", (event) => { + if (event.key !== "Enter" || !event.target.closest(".item")) return; + event.preventDefault(); + const next = event.target.closest(".item").nextElementSibling; + if (next?.matches(".item")) next.querySelector(".person input").focus(); + else addRow(); + }); + + let dragging = null; + + list.addEventListener("dragstart", (event) => { + const item = event.target.closest(".item"); + if (!item) return; + dragging = item; + item.classList.add("dragging"); + }); + + list.addEventListener("dragend", () => { + dragging?.classList.remove("dragging"); + dragging = null; + }); + + list.addEventListener("dragover", (event) => { + if (!dragging) return; + event.preventDefault(); + const target = event.target.closest(".item"); + if (!target || target === dragging) return; + const rect = target.getBoundingClientRect(); + const before = event.clientY - rect.top < rect.height / 2; + list.insertBefore(dragging, before ? target : target.nextSibling); + }); + + function sync() { + const rows = Array.from(list.querySelectorAll(".item")).map((item) => ({ + value: item.querySelector(".person input").value, + role: item.querySelector(".role").value, + })); + field.value = JSON.stringify(rows); + } + + const form = list.closest("form"); + if (form) form.addEventListener("submit", sync); + list.addEventListener("input", sync); + new MutationObserver(sync).observe(list, { childList: true }); + sync(); + } +} + +customElements.define("credits-input", CreditsInput); diff --git a/assets/components/media_input.css b/assets/components/media_input.css index 4746ce5..88cd9fd 100644 --- a/assets/components/media_input.css +++ b/assets/components/media_input.css @@ -1,4 +1,5 @@ -media-input { +media-input, +credits-input { display: flex; flex-direction: column; gap: var(--space-2); @@ -52,15 +53,36 @@ media-input { } } - & .alt { - flex: 1; + & :is(.alt, .role, .person input) { min-inline-size: 0; - padding: 0 var(--space-2); border: 2px solid var(--outline); border-radius: var(--space-2); background: var(--paper); } + & :is(.alt, .role) { + flex: 1; + padding: 0 var(--space-2); + } + + & .person { + flex: 2; + min-inline-size: 0; + + & input { + inline-size: 100%; + padding-inline: var(--space-2); + } + + &.input-icon input { + padding-inline-start: var(--space-6); + } + } + + & .add-person { + align-self: flex-start; + } + & .remove { all: unset; cursor: pointer; diff --git a/assets/components/share_form.js b/assets/components/share_form.js index 847032b..f11fc02 100644 --- a/assets/components/share_form.js +++ b/assets/components/share_form.js @@ -69,6 +69,7 @@ class ShareForm extends HTMLElement { title: form.elements.title.value.trim(), game: game(), description: form.elements.description.value.trim(), + credits: form.elements.credits.value, media: form.elements.media.value, key, "external-url": chosen ? "" : download.link.value.trim(), diff --git a/flake.nix b/flake.nix index b936c83..e22d90c 100644 --- a/flake.nix +++ b/flake.nix @@ -352,6 +352,16 @@ }; }; + staffDid = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "did:plc:tjgdahiw3u2djgnigyqeummg"; + description = '' + The account whose mods can credit people by name rather than + by account, for mods it lists on their makers' behalf. + ''; + }; + signup = { pdsUrl = lib.mkOption { type = lib.types.str; @@ -463,6 +473,10 @@ (toString cfg.signup.turnstileCredentialsFile) ] ++ lib.optional cfg.s3.virtualHost "--s3-virtual-host" + ++ lib.optionals (cfg.staffDid != null) [ + "--staff-did" + cfg.staffDid + ] ++ lib.optionals (cfg.signup.pdsInternalUrl != null) [ "--signup-pds-internal-url" cfg.signup.pdsInternalUrl diff --git a/migrations/20261022000000_mod_listing_credits.sql b/migrations/20261022000000_mod_listing_credits.sql new file mode 100644 index 0000000..9b549ca --- /dev/null +++ b/migrations/20261022000000_mod_listing_credits.sql @@ -0,0 +1,25 @@ +-- The people credited for a mod, as a JSON array of names and roles, shown +-- on its page and in place of its owner's name where a mod is listed. +ALTER TABLE mod_listing ADD COLUMN credits TEXT NOT NULL DEFAULT '[]'; + +-- Recreated rather than ALTER TABLE'd: this index is derived from +-- mod_listing and rebuilt at startup, so there is nothing here worth +-- preserving across the change. +DROP TABLE mod_search; + +CREATE VIRTUAL TABLE mod_search USING fts5 +( + title, + description, + details, + game, + platform, + tags, + author, + credits, + + author_did UNINDEXED, + rkey UNINDEXED, + + tokenize = 'porter unicode61' +); diff --git a/src/atproto/lexicon/mod_listing.rs b/src/atproto/lexicon/mod_listing.rs index f057684..de359ab 100644 --- a/src/atproto/lexicon/mod_listing.rs +++ b/src/atproto/lexicon/mod_listing.rs @@ -211,10 +211,37 @@ pub struct ModListing { pub license: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub media: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub credits: Vec, #[serde(with = "atproto_record::datetime::format")] pub created_at: DateTime, } +/// Someone credited for a mod: an account, or, for a mod listed on its makers' +/// behalf, just a name, since they may have no account. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Credit { + /// The credited account, named by its profile wherever the credit is + /// shown. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub did: Option, + /// The credited person, for a credit that isn't an account. Empty if + /// `did` is set. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub name: String, + /// What they did, such as "Music". Empty if not given. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub role: String, +} + +impl Credit { + /// How many people a mod can credit. + pub const MAX: usize = 20; + + /// The longest a name or a role can be, in graphemes. + pub const MAX_GRAPHEMES: usize = 64; +} + /// An item in a mod's media gallery: an image stored as a PDS blob, or a link /// to a YouTube video. /// @@ -343,7 +370,7 @@ impl ModListing { pub async fn load_local(db: &SqlitePool, did: &Did, rkey: &Rkey) -> Result> { let row = sqlx::query!( "SELECT title, slug, description, details, game, - license, tags, media, created_at + license, tags, media, credits, created_at FROM mod_listing WHERE author_did = ? AND rkey = ?", did, rkey @@ -361,6 +388,7 @@ impl ModListing { license: r.license, tags: Tags(serde_json::from_str(&r.tags)?), media: serde_json::from_str(&r.media)?, + credits: serde_json::from_str(&r.credits)?, created_at: r.created_at.and_utc(), }) }) @@ -379,7 +407,7 @@ impl ModListing { ) -> Result> { let row = sqlx::query!( "SELECT rkey, title, slug, description, details, game, - license, tags, media, created_at + license, tags, media, credits, created_at FROM mod_listing WHERE author_did = ? AND slug = ? ORDER BY rkey LIMIT 1", did, slug @@ -400,6 +428,7 @@ impl ModListing { license: r.license, tags: Tags(serde_json::from_str(&r.tags)?), media: serde_json::from_str(&r.media)?, + credits: serde_json::from_str(&r.credits)?, created_at: r.created_at.and_utc(), }; Ok(Some((rkey, listing))) @@ -409,7 +438,7 @@ impl ModListing { pub async fn find_by_author(db: &SqlitePool, did: &Did) -> Result> { let rows = sqlx::query!( "SELECT rkey, title, slug, description, details, game, - license, tags, media, created_at + license, tags, media, credits, created_at FROM mod_listing WHERE author_did = ? ORDER BY created_at DESC", did ) @@ -429,6 +458,7 @@ impl ModListing { license: r.license, tags: Tags(serde_json::from_str(&r.tags)?), media: serde_json::from_str(&r.media)?, + credits: serde_json::from_str(&r.credits)?, created_at: r.created_at.and_utc(), }, )) @@ -480,6 +510,17 @@ impl Record for ModListing { } } + if self.credits.len() > Credit::MAX { + bail!("credits has more than {} entries", Credit::MAX); + } + for credit in &self.credits { + if credit.did.is_some() == !credit.name.is_empty() { + bail!("a credit names an account or a person, not both or neither"); + } + max_graphemes("credit name", &credit.name, Credit::MAX_GRAPHEMES)?; + max_graphemes("credit role", &credit.role, Credit::MAX_GRAPHEMES)?; + } + Ok(()) } @@ -508,11 +549,12 @@ impl Record for ModListing { /// happens to share a slug. async fn save_local(&self, db: &SqlitePool, did: &Did, rkey: &Rkey) -> Result<()> { let media_json = serde_json::to_string(&self.media)?; + let credits_json = serde_json::to_string(&self.credits)?; sqlx::query!( "INSERT INTO mod_listing (author_did, rkey, title, slug, description, details, game, - license, tags, media, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + license, tags, media, credits, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (author_did, rkey) DO UPDATE SET title = excluded.title, slug = excluded.slug, @@ -522,6 +564,7 @@ impl Record for ModListing { license = excluded.license, tags = excluded.tags, media = excluded.media, + credits = excluded.credits, created_at = excluded.created_at", did, rkey, @@ -533,6 +576,7 @@ impl Record for ModListing { self.license, self.tags, media_json, + credits_json, self.created_at ) .execute(db) @@ -654,6 +698,7 @@ mod tests { tags: Tags(vec!["hard".to_string()]), license: String::new(), media: vec![], + credits: vec![], created_at: Utc::now(), } } @@ -744,6 +789,70 @@ mod tests { .is_some()); } + #[test] + fn a_credit_names_an_account_or_a_person() { + let mut listing = listing(); + let account = Credit { + did: Some(Did::new("did:plc:wrymouth")), + name: String::new(), + role: String::new(), + }; + let person = Credit { + did: None, + name: "Quackles".to_string(), + role: "Art".to_string(), + }; + listing.credits = vec![account.clone(), person.clone()]; + assert!(listing.validate().is_ok()); + + let both = Credit { + name: "Wrymouth".to_string(), + ..account.clone() + }; + let neither = Credit { + did: None, + name: String::new(), + role: "Art".to_string(), + }; + let too_long = Credit { + role: "a".repeat(65), + ..person + }; + for bad in [both, neither, too_long] { + listing.credits = vec![bad]; + assert!(listing.validate().is_err()); + } + listing.credits = vec![account; 21]; + assert!(listing.validate().is_err()); + } + + #[tokio::test] + async fn credits_survive_saving_and_loading() { + let db = test_db().await; + let did = Did::new("did:plc:example"); + let rkey = Rkey::new(); + let mut original = listing(); + original.credits = vec![ + Credit { + did: Some(Did::new("did:plc:wrymouth")), + name: String::new(), + role: "Music".to_string(), + }, + Credit { + did: None, + name: "Quackles".to_string(), + role: String::new(), + }, + ]; + original.save_local(&db, &did, &rkey).await.unwrap(); + + let loaded = ModListing::load_local(&db, &did, &rkey) + .await + .unwrap() + .unwrap(); + assert_eq!(loaded.credits, original.credits); + } + #[test] fn rejects_an_unrecognized_game_in_a_record() { let mut value = serde_json::to_value(listing()).unwrap(); diff --git a/src/components/credits_input.rs b/src/components/credits_input.rs new file mode 100644 index 0000000..c1cf3ab --- /dev/null +++ b/src/components/credits_input.rs @@ -0,0 +1,52 @@ +use maud::{html, Markup}; + +use crate::credits::Row; +use crate::page::icon; + +/// A reorderable list of credits that owns its add button and hidden form +/// field. Each row is a handle, or a name for the staff account's mods, and a +/// role; the finished order serialises into `name` on submit. `staff` is +/// whether people are named rather than credited by handle. +pub fn render(name: &str, rows: &[Row], staff: bool) -> Markup { + html! { + credits-input { + template { (item(&Row::default(), staff)) } + @for row in rows { (item(row, staff)) } + button.secondary.add-person type="button" { + (icon(icondata::LuPlus)) + "Add person" + } + input type="hidden" name=(name); + } + } +} + +/// One credit. A handle is drawn like sign-in's, with the @ inside the field; +/// a name is just a field. +fn item(row: &Row, staff: bool) -> Markup { + let value = (!row.value.is_empty()).then_some(&row.value); + html! { + div.item { + span.handle draggable="true" aria-hidden="true" { + (icon(icondata::LuGripVertical)) + } + @if staff { + div.person { + input type="text" placeholder="Name" aria-label="Credited person" value=[value]; + } + } @else { + div.person.input-icon { + (icon(icondata::LuAtSign)) + input type="text" placeholder="alex.starhaven.dev" aria-label="Credited person" + autocapitalize="none" autocorrect="off" inputmode="url" spellcheck="false" + value=[value]; + } + } + input type="text" class="role" placeholder="Role (optional)" + aria-label="Role" value=[(!row.role.is_empty()).then_some(&row.role)]; + button type="button" class="remove" aria-label="Remove person" { + (icon(icondata::LuX)) + } + } + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index e2d2b5a..bb8906b 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1 +1,2 @@ +pub mod credits_input; pub mod media_input; diff --git a/src/config.rs b/src/config.rs index 5010916..7ce67f8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -150,6 +150,12 @@ pub struct Config { #[arg(long, default_value = "test")] pub signup_handle_domain: String, + /// The account whose mods can credit people by name rather than by + /// account, for mods it lists on their makers' behalf. With none, every + /// credit is an account. + #[arg(long)] + pub staff_did: Option, + /// Path to a JSON file holding the `identifier` and `password` of an /// admin account on the sign-up PDS, which mints the invite code each new /// account needs. Required unless that PDS is loopback, which is taken to diff --git a/src/credits.rs b/src/credits.rs new file mode 100644 index 0000000..efcc3f9 --- /dev/null +++ b/src/credits.rs @@ -0,0 +1,320 @@ +//! The people credited for a mod, and how they're named, edited, and listed. +//! +//! A credit is an account, named by its profile wherever it's shown, or, for +//! the staff account's mods, a person's name, since the people who made a mod +//! it listed on their behalf may have no account. The editor sends each as a +//! row of what was typed. + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use sqlx::SqlitePool; + +use crate::atproto::id::{Did, Handle}; +use crate::atproto::lexicon::mod_listing::Credit; +use crate::atproto::lexicon::ActorProfile; +use crate::state::AppState; + +/// A credit as the editor shows it: the handle, or for a person without an +/// account the name, and the role. +#[derive(Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Row { + #[serde(default)] + pub value: String, + #[serde(default)] + pub role: String, +} + +/// A credit as it's shown: who, and what they did. +pub struct Credited { + pub name: String, + /// Where to find an account's page. None for a person. + pub handle: Option, + pub avatar_url: Option, + pub role: String, +} + +/// Where a mod's credits are shown briefly, such as on its card. +pub struct Byline { + /// The avatars of the first few credited accounts, to stack. + pub avatar_urls: Vec, + /// Every credited name in order, for the page to fit as many as it can. + pub names: String, +} + +/// How many avatars a byline stacks. +const BYLINE_AVATARS: usize = 3; + +/// Whether `did` is the account whose mods can credit people by name. +pub fn is_staff(state: &AppState, did: &Did) -> bool { + state.config.staff_did.as_deref() == Some(did.as_str()) +} + +async fn resolve_one(db: &SqlitePool, credit: &Credit) -> Result { + let Some(did) = &credit.did else { + return Ok(Credited { + name: credit.name.clone(), + handle: None, + avatar_url: None, + role: credit.role.clone(), + }); + }; + let handle = did.cached_handle(db).await?; + let profile = ActorProfile::load(db, did).await?; + let fallback = handle.as_ref().map_or(did.as_str(), Handle::as_str); + Ok(Credited { + name: profile.name(fallback).to_string(), + avatar_url: Some(profile.avatar_url(did)), + handle, + role: credit.role.clone(), + }) +} + +/// `credits`, each named as its account is. +pub async fn resolve(db: &SqlitePool, credits: &[Credit]) -> Result> { + let mut credited = Vec::with_capacity(credits.len()); + for credit in credits { + credited.push(resolve_one(db, credit).await?); + } + Ok(credited) +} + +/// `credits` as a byline, or None if there are none. +pub async fn byline(db: &SqlitePool, credits: &[Credit]) -> Result> { + let credited = resolve(db, credits).await?; + if credited.is_empty() { + return Ok(None); + } + Ok(Some(Byline { + avatar_urls: credited + .iter() + .filter_map(|credit| credit.avatar_url.clone()) + .take(BYLINE_AVATARS) + .collect(), + names: credited + .iter() + .map(|credit| credit.name.as_str()) + .collect::>() + .join(", "), + })) +} + +/// What a search matches a mod's credits by: each person's names and role. +pub async fn search_text(db: &SqlitePool, credits: &[Credit]) -> Result { + let mut text = Vec::with_capacity(credits.len()); + for credit in credits { + let handle = match &credit.did { + Some(did) => did + .cached_handle(db) + .await? + .map(|handle| handle.to_string()), + None => None, + }; + let credited = resolve_one(db, credit).await?; + text.push(format!( + "{} {} {}", + credited.name, + handle.unwrap_or_default(), + credit.role + )); + } + Ok(text.join(" ")) +} + +/// `credits` as the editor shows them. +pub async fn rows(db: &SqlitePool, credits: &[Credit]) -> Result> { + let mut rows = Vec::with_capacity(credits.len()); + for credit in credits { + let value = match &credit.did { + Some(did) => did + .cached_handle(db) + .await? + .map_or_else(|| did.to_string(), |handle| handle.to_string()), + None => credit.name.clone(), + }; + rows.push(Row { + value, + role: credit.role.clone(), + }); + } + Ok(rows) +} + +/// The rows a failed save came with, for the editor to show again. Whatever +/// couldn't be read is left out. +pub fn rows_from_json(json: &str) -> Vec { + serde_json::from_str(json).unwrap_or_default() +} + +/// Credits from the rows the editor sent for a mod of `owner`'s, or a message +/// for whoever sent them. +/// +/// A mod of the staff account names people; any other credits accounts by +/// handle, and has to include its owner, since the credits are all that says +/// whose it is. +pub async fn parse_field(state: &AppState, owner: &Did, json: &str) -> Result, String> { + let rows: Vec = if json.trim().is_empty() { + vec![] + } else { + serde_json::from_str(json).map_err(|_| "The credits could not be read".to_string())? + }; + let staff = is_staff(state, owner); + + let mut credits = Vec::new(); + for row in rows { + let value = row.value.trim().trim_start_matches('@'); + let role = row.role.trim().to_string(); + if value.is_empty() && role.is_empty() { + continue; + } + if value.is_empty() { + return Err(if staff { + "Enter a name for each credit".to_string() + } else { + "Enter a handle for each credit".to_string() + }); + } + if value.chars().count() > Credit::MAX_GRAPHEMES + || role.chars().count() > Credit::MAX_GRAPHEMES + { + return Err("A name or a role must be 64 characters or less".to_string()); + } + credits.push(if staff { + Credit { + did: None, + name: value.to_string(), + role, + } + } else { + Credit { + did: Some(find_account(state, value).await?), + name: String::new(), + role, + } + }); + } + + if credits.len() > Credit::MAX { + return Err(format!("Credit at most {} people", Credit::MAX)); + } + if !staff && !credits.is_empty() && !credits.iter().any(|c| c.did.as_ref() == Some(owner)) { + return Err("Credit yourself too, since the mod is listed under your account".to_string()); + } + Ok(credits) +} + +/// The account `value` is the handle of. +async fn find_account(state: &AppState, value: &str) -> Result { + let handle = + Handle::new(value).map_err(|_| format!("{value} isn't a handle, such as alice.test"))?; + match handle.resolve_did(state).await { + Ok(Some(did)) => Ok(did), + Ok(None) => Err(format!("No account has the handle {value}")), + Err(err) => { + eprintln!("failed to resolve {handle} for a credit: {err:?}"); + Err(format!( + "The handle {value} could not be checked - try again" + )) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::atproto::actor; + + async fn db_with_account(did: &Did, handle: &str) -> SqlitePool { + let db = SqlitePool::connect("sqlite::memory:").await.unwrap(); + sqlx::migrate!().run(&db).await.unwrap(); + actor::upsert(&db, did, &Handle::new(handle).unwrap()) + .await + .unwrap(); + db + } + + fn account(did: &Did, role: &str) -> Credit { + Credit { + did: Some(did.clone()), + name: String::new(), + role: role.to_string(), + } + } + + fn person(name: &str) -> Credit { + Credit { + did: None, + name: name.to_string(), + role: String::new(), + } + } + + #[tokio::test] + async fn an_account_is_named_by_its_handle_until_it_has_a_display_name() { + let did = Did::new("did:plc:wrymouth"); + let db = db_with_account(&did, "wrymouth.test").await; + + let credited = resolve(&db, &[account(&did, "Music")]).await.unwrap(); + assert_eq!(credited[0].name, "wrymouth.test"); + assert_eq!( + credited[0].handle.as_ref().unwrap().as_str(), + "wrymouth.test" + ); + assert_eq!(credited[0].role, "Music"); + assert!(credited[0].avatar_url.is_some()); + + let credited = resolve(&db, &[person("Homer")]).await.unwrap(); + assert_eq!(credited[0].name, "Homer"); + assert!(credited[0].handle.is_none() && credited[0].avatar_url.is_none()); + } + + #[tokio::test] + async fn a_byline_lists_every_name_and_stacks_a_few_avatars() { + let did = Did::new("did:plc:wrymouth"); + let db = db_with_account(&did, "wrymouth.test").await; + let credits = [ + account(&did, ""), + person("B"), + account(&did, "Art"), + person("D"), + ]; + + let all = byline(&db, &credits).await.unwrap().unwrap(); + assert_eq!(all.names, "wrymouth.test, B, wrymouth.test, D"); + // A person has no avatar to stack. + assert_eq!(all.avatar_urls.len(), 2); + + let people = byline(&db, &credits[1..2]).await.unwrap().unwrap(); + assert_eq!(people.names, "B"); + assert!(people.avatar_urls.is_empty()); + + let many = vec![account(&did, ""); 5]; + assert_eq!( + byline(&db, &many).await.unwrap().unwrap().avatar_urls.len(), + 3 + ); + assert!(byline(&db, &[]).await.unwrap().is_none()); + } + + #[tokio::test] + async fn the_editor_gets_back_handles_and_names() { + let did = Did::new("did:plc:wrymouth"); + let db = db_with_account(&did, "wrymouth.test").await; + let rows = rows(&db, &[account(&did, "Music"), person("Homer")]) + .await + .unwrap(); + assert_eq!( + rows, + [ + Row { + value: "wrymouth.test".to_string(), + role: "Music".to_string() + }, + Row { + value: "Homer".to_string(), + role: String::new() + }, + ] + ); + assert!(rows_from_json("not json").is_empty()); + } +} diff --git a/src/featured.rs b/src/featured.rs index edc28e0..175edd1 100644 --- a/src/featured.rs +++ b/src/featured.rs @@ -122,6 +122,7 @@ mod tests { tags: Tags::default(), license: String::new(), media: vec![], + credits: vec![], created_at: chrono::Utc::now(), }; listing diff --git a/src/homepage.rs b/src/homepage.rs index d3a943f..3f83b0a 100644 --- a/src/homepage.rs +++ b/src/homepage.rs @@ -13,8 +13,9 @@ use serde::Deserialize; use sqlx::SqlitePool; use crate::atproto::id::{Did, Handle, Rkey}; -use crate::atproto::lexicon::mod_listing::{Game, MediaItem}; +use crate::atproto::lexicon::mod_listing::{Credit, Game, MediaItem}; use crate::atproto::lexicon::{ActorProfile, ModListing, ModStar}; +use crate::credits::{self, Byline}; use crate::error::AppError; use crate::featured; use crate::markdown; @@ -62,6 +63,9 @@ pub struct Card { pub description: String, pub game: Game, pub media: Vec, + /// Who's credited, in place of the account that listed the mod, if + /// anyone is. + pub byline: Option, } impl Card { @@ -142,7 +146,7 @@ pub async fn mods( let since = format!("-{} days", POPULAR_DAYS - 1); let rows = sqlx::query!( r#"WITH p AS (SELECT date('now', ?) AS since, ? AS stars, ? AS downloads, ? AS views) - SELECT author_did, rkey, slug, title, description, game, media, + SELECT author_did, rkey, slug, title, description, game, media, credits, stars AS "stars!: i64" FROM ( SELECT m.*, @@ -152,7 +156,7 @@ pub async fn mods( AS score FROM ( SELECT l.author_did, l.rkey, l.slug, l.title, l.description, l.game, l.media, - l.created_at, + l.credits, l.created_at, coalesce(s.recent_downloads, 0) AS recent_downloads, coalesce(s.downloads, 0) AS downloads, coalesce(s.recent_views, 0) AS recent_views, @@ -216,6 +220,8 @@ pub async fn mods( description: row.description, game: row.game.parse()?, media: serde_json::from_str(&row.media)?, + byline: credits::byline(db, &serde_json::from_str::>(&row.credits)?) + .await?, }); } Ok(cards) @@ -249,6 +255,7 @@ pub async fn card(db: &SqlitePool, did: Did, rkey: &Rkey, listing: ModListing) - description: listing.description, game: listing.game, media: listing.media, + byline: credits::byline(db, &listing.credits).await?, }) } @@ -567,9 +574,22 @@ fn mod_section(sort: Sort, game: Option, cards: &[Card]) -> Markup { fn card_byline(card: &Card, class: &str) -> Markup { html! { span class=(class) { - img.byline-avatar src=(card.avatar_url) alt="" loading="lazy" decoding="async"; - @if let Some(name) = card.author_name() { - span.byline-name { (name) } + // Credited names stand in for the account that listed the mod, + // which for a mod listed on its makers' behalf is no one's. + @if let Some(byline) = &card.byline { + @if !byline.avatar_urls.is_empty() { + span.byline-avatars { + @for avatar_url in &byline.avatar_urls { + img.byline-avatar src=(avatar_url) alt="" loading="lazy" decoding="async"; + } + } + } + span.byline-names { (byline.names) } + } @else { + img.byline-avatar src=(card.avatar_url) alt="" loading="lazy" decoding="async"; + @if let Some(name) = card.author_name() { + span.byline-name { (name) } + } } span.byline-stars.byline-starred[card.starred] { (icon(icondata::LuStar)) @@ -645,6 +665,7 @@ mod tests { tags: Tags::default(), license: String::new(), media: vec![], + credits: vec![], created_at: created_at.parse().unwrap(), }; listing diff --git a/src/main.rs b/src/main.rs index 2e335e3..70590e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod atproto; mod blobs; mod components; mod config; +mod credits; mod deliverables; mod error; mod featured; diff --git a/src/mods.rs b/src/mods.rs index bab4d00..47a8e69 100644 --- a/src/mods.rs +++ b/src/mods.rs @@ -21,6 +21,7 @@ use crate::atproto::lexicon::mod_star::Stars; use crate::atproto::lexicon::mod_version::external_host; use crate::atproto::lexicon::{ActorProfile, Found, ModListing, ModStar, ModVersion, Record}; use crate::atproto::pds; +use crate::credits::{self, Credited, Row}; use crate::deliverables; use crate::error::AppError; use crate::form; @@ -179,8 +180,26 @@ async fn moved( } } -async fn handle_get_create(_session: SessionCookie, page: Page) -> Html { - page.body(share_form()).title("Share a mod").render() +async fn handle_get_create( + State(state): State, + session: SessionCookie, + page: Page, +) -> Result, AppError> { + let staff = credits::is_staff(&state, &session.did); + // A mod's credits name whose it is, so a new one starts with its owner. + let rows = if staff { + vec![] + } else { + let handle = session.did.resolve_handle(&state).await?; + vec![Row { + value: handle.map_or_else(|| session.did.to_string(), |handle| handle.to_string()), + role: String::new(), + }] + }; + Ok(page + .body(share_form(&rows, staff)) + .title("Share a mod") + .render()) } /// Everything a mod needs to be shared, published in one go: its listing and @@ -190,7 +209,7 @@ async fn handle_get_create(_session: SessionCookie, page: Page) -> Html /// Not a plain form post: the images and the version's file upload as /// they're chosen and as it's sent, so `` drives it, and there is /// nothing useful to fall back to without it. -fn share_form() -> Markup { +fn share_form(credit_rows: &[Row], staff: bool) -> Markup { let game_options: Vec<(&str, Game)> = Game::ALL.iter().map(|g| (g.as_str(), *g)).collect(); // The file kinds each game's versions can upload, for the games whose // files Star Haven hosts. The rest link to theirs. @@ -213,6 +232,7 @@ fn share_form() -> Markup { (form::text_field("title", "Title", "", None, None)) (form::radio_group("game", "Game the mod is for", &game_options, None, None)) (form::text_field("description", "Short description", "", None, None)) + (credits_field(credit_rows, staff, None)) (media_gallery_field("/create/media", &[], None)) (download_fields(None)) @@ -227,6 +247,21 @@ fn share_form() -> Markup { } } +/// The credits field, whose editor sends its rows as JSON for +/// [`credits::parse_field`] to read. +fn credits_field(rows: &[Row], staff: bool, error: Option<&str>) -> Markup { + html! { + div.form-item id="credits" { + label { "Credits" } + div.error id="credits-error" hidden[error.is_none()] { (error.unwrap_or_default()) } + noscript { + p.error { "Editing the credits needs JavaScript." } + } + (crate::components::credits_input::render("credits", rows, staff)) + } + } +} + /// What a version's download asks for when its game's files can't be /// uploaded, only linked to. const LINK_ONLY_HINT: &str = "Paste a link to where players download it."; @@ -312,6 +347,9 @@ struct ShareRequest { game: String, #[serde(default)] description: String, + /// The credits editor's rows, as JSON. + #[serde(default)] + credits: String, /// The gallery editor's JSON list of uploaded images. #[serde(default)] media: String, @@ -354,6 +392,10 @@ async fn handle_post_create( let Ok(media) = parse_media_field(&request.media) else { return Err(refuse("The images could not be read - try adding them again").await); }; + let credits = match credits::parse_field(&state, &session.did, &request.credits).await { + Ok(credits) => credits, + Err(message) => return Err(refuse_upload(&state, key, &message).await), + }; // A video can't stand in for the image that cards and search show. if !media.iter().any(MediaItem::is_image) { return Err(refuse("Add an image").await); @@ -395,6 +437,7 @@ async fn handle_post_create( tags: Tags::default(), license: String::new(), media, + credits, created_at: Utc::now(), }; if let Err(err) = listing.update(&state, &session, &rkey).await { @@ -462,32 +505,46 @@ async fn mod_layout( .await .map_err(AppError::Internal)?; let handle = existing.handle.to_string(); + let credited = credits::resolve(&existing.state().db, &listing.credits) + .await + .map_err(AppError::Internal)?; Ok(html! { main.mod-listing.accent-blue id="mod" { (mod_carousel(&existing.did, listing)) div.card { - h1 { (listing.title) } - p.mod-game { (listing.game) } - a.mod-author href={ "/" (handle) } { - img src=(author.avatar_url(&existing.did)) alt=""; - span.mod-author-names { - span.mod-author-name { (author.name(&handle)) } - @if !author.display_name.is_empty() { - span.mod-author-handle { "@" (handle) } + div.mod-summary { + h1 { (listing.title) } + p.mod-game { "For " (listing.game) } + // The credits name whose mod it is, but for a mod with none. + @if credited.is_empty() { + a.mod-author href={ "/" (handle) } { + img src=(author.avatar_url(&existing.did)) alt=""; + span.mod-author-names { + span.mod-author-name { (author.name(&handle)) } + @if !author.display_name.is_empty() { + span.mod-author-handle { "@" (handle) } + } + } } } - } - @match latest { - Some((rkey, latest)) => { - (download_link(existing, rkey, latest, "button accent-blue download-button", Some(&latest.name))) + @match latest { + Some((rkey, latest)) => { + (download_link(existing, rkey, latest, "button accent-blue download-button", Some(&latest.name))) + } + None => { + p.hint { "This mod has no downloads yet." } + } } - None => { - p.hint { "This mod has no downloads yet." } + @if let Some(stars) = stars { + (star_button(existing, stars)) } } - @if let Some(stars) = stars { - (star_button(existing, stars)) + @if !credited.is_empty() { + div.mod-credits-section { + h2.mod-credits-heading { "Credits" } + (credits_list(&credited)) + } } } section.mod-details id="mod-details" { @@ -498,6 +555,38 @@ async fn mod_layout( }) } +/// Everyone credited for a mod, each with what they did. +fn credits_list(credited: &[Credited]) -> Markup { + html! { + ul.mod-credits { + @for credit in credited { + li.mod-credit { + @if let Some(avatar_url) = &credit.avatar_url { + img src=(avatar_url) alt=""; + } + span.mod-credit-names { + span.mod-credit-who { + @if let Some(handle) = &credit.handle { + a.mod-credit-name href={ "/" (handle) } { (credit.name) } + } @else { + span.mod-credit-name { (credit.name) } + } + // The handle too, as the author's is, if the name + // isn't it. + @if let Some(handle) = credit.handle.as_ref().filter(|h| h.as_str() != credit.name) { + span.mod-credit-handle { "@" (handle) } + } + } + @if !credit.role.is_empty() { + span.mod-credit-role { (credit.role) } + } + } + } + } + } + } +} + /// A link to download `version`, labeled with its `name` if given. It goes /// through Star Haven, which counts it, whether the file is here or hosted /// elsewhere, which the link names. @@ -757,6 +846,20 @@ async fn settings_page( errors: &EditFormErrors<'_>, ) -> Result { let listing = &existing.record; + let mut credit_rows = match errors.credits_input { + Some(json) => credits::rows_from_json(json), + None => credits::rows(&existing.state().db, &listing.credits) + .await + .map_err(AppError::Internal)?, + }; + // A mod's credits name whose it is, so one without any starts with its + // owner. + if credit_rows.is_empty() && !credits::is_staff(existing.state(), &existing.did) { + credit_rows.push(Row { + value: existing.handle.to_string(), + role: String::new(), + }); + } mod_layout( existing, latest, @@ -765,7 +868,7 @@ async fn settings_page( match transfer { Some(transfer) if transfer.in_progress => crate::transfer::progress(existing, transfer), _ => html! { - (edit_form(existing, errors)) + (edit_form(existing, &credit_rows, errors)) (crate::transfer::section(existing, transfer)) (delete_form(&existing.handle, listing)) }, @@ -826,7 +929,11 @@ fn version_form(handle: &Handle, listing: &ModListing) -> Markup { struct EditFormErrors<'a> { general: Option<&'a str>, title: Option<&'a str>, + credits: Option<&'a str>, media: Option<&'a str>, + /// The credits editor's rows, when they couldn't be saved, so the author + /// gets them back to fix. + credits_input: Option<&'a str>, } impl EditFormErrors<'_> { @@ -836,6 +943,7 @@ impl EditFormErrors<'_> { [ self.general.map(|e| (None, e)), self.title.map(|e| (Some("title"), e)), + self.credits.map(|e| (Some("credits"), e)), self.media.map(|e| (Some("media"), e)), ] .into_iter() @@ -846,7 +954,7 @@ impl EditFormErrors<'_> { /// Takes the whole listing rather than a field each, so what it renders back /// after a rejected save is whatever the caller put on the record. -fn edit_form(existing: &Found, errors: &EditFormErrors) -> Markup { +fn edit_form(existing: &Found, credit_rows: &[Row], errors: &EditFormErrors) -> Markup { let listing = &existing.record; html! { @@ -859,6 +967,11 @@ fn edit_form(existing: &Found, errors: &EditFormErrors) -> Markup { (form::text_field("description", "Short description", &listing.description, None, None)) (form::textarea_field("details", "Details", &listing.details, None, None)) (form::text_field("license", "License", &listing.license, None, None)) + (credits_field( + credit_rows, + credits::is_staff(existing.state(), &existing.did), + errors.credits, + )) (media_gallery_field( &format!("/{}/{}/media", existing.handle, listing.slug), &gallery_items(&existing.did, listing), @@ -938,6 +1051,8 @@ struct EditListingForm { details: String, #[serde(default)] license: String, + #[serde(default)] + credits: String, /// The gallery editor's current order, as JSON - built client side from /// the blob refs `handle_post_media` handed back. Empty for a mod with /// no gallery, or when the editor's JS never ran. @@ -1040,6 +1155,15 @@ async fn handle_post_settings( existing.record.details = form.details; existing.record.license = form.license; existing.record.media = media; + let credits_error; + match credits::parse_field(existing.state(), &existing.did, &form.credits).await { + Ok(credits) => existing.record.credits = credits, + Err(message) => { + credits_error = message; + errors.credits = Some(&credits_error); + errors.credits_input = Some(&form.credits); + } + } if existing.record.title.is_empty() { errors.title = Some("Enter a title"); } else if existing.record.title.chars().count() > 300 { @@ -1050,7 +1174,7 @@ async fn handle_post_settings( errors.media = Some("Add an image"); } - if errors.title.is_some() || errors.media.is_some() { + if errors.title.is_some() || errors.credits.is_some() || errors.media.is_some() { let body = match settings_page(&existing, latest.as_ref(), transfer.as_ref(), &errors).await { Ok(body) => body, diff --git a/src/page.rs b/src/page.rs index 9728a6d..906b94f 100644 --- a/src/page.rs +++ b/src/page.rs @@ -129,6 +129,7 @@ impl Page { link rel="stylesheet" href="/assets/components/media_input.css"; script type="module" src="/assets/components/media_carousel.js" {} script type="module" src="/assets/components/media_input.js" {} + script type="module" src="/assets/components/credits_input.js" {} script type="module" src="/assets/components/upload_dialog.js" {} script type="module" src="/assets/components/download_fields.js" {} script type="module" src="/assets/components/share_form.js" {} diff --git a/src/search.rs b/src/search.rs index 1bbf570..7aa4be6 100644 --- a/src/search.rs +++ b/src/search.rs @@ -15,6 +15,7 @@ use sqlx::{Row, SqlitePool}; use crate::atproto::id::{Did, Handle, Rkey}; use crate::atproto::lexicon::ModListing; +use crate::credits; use crate::error::AppError; use crate::homepage; use crate::page::{icon, Page}; @@ -46,11 +47,13 @@ pub async fn index(db: &SqlitePool, did: &Did, rkey: &Rkey, listing: &ModListing let author_str = author.map(|h| h.as_str().to_string()).unwrap_or_default(); let platform = listing.platform(); + // Names and roles both, so "music" finds the mods someone made music for. + let credits = credits::search_text(db, &listing.credits).await?; sqlx::query!( "INSERT INTO mod_search (title, description, details, game, platform, tags, - author, author_did, rkey) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + author, credits, author_did, rkey) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", listing.title, listing.description, listing.details, @@ -58,6 +61,7 @@ pub async fn index(db: &SqlitePool, did: &Did, rkey: &Rkey, listing: &ModListing platform, tags, author_str, + credits, did, rkey ) @@ -131,7 +135,7 @@ pub async fn search(db: &SqlitePool, input: &str) -> Result> "SELECT author_did, rkey FROM mod_search WHERE mod_search MATCH ? - ORDER BY bm25(mod_search, 10.0, 3.0, 1.0, 5.0, 5.0, 3.0, 3.0, 8.0) + ORDER BY bm25(mod_search, 10.0, 3.0, 1.0, 5.0, 5.0, 3.0, 3.0, 8.0, 8.0) LIMIT ?", ) .bind(query) @@ -356,7 +360,7 @@ fn results( mod tests { use super::*; use crate::atproto::actor; - use crate::atproto::lexicon::mod_listing::{Game, Tags}; + use crate::atproto::lexicon::mod_listing::{Credit, Game, Tags}; use crate::atproto::lexicon::{ActorProfile, Record}; #[test] @@ -385,6 +389,7 @@ mod tests { tags: Tags::default(), license: String::new(), media: vec![], + credits: vec![], created_at: chrono::Utc::now(), }; listing @@ -484,6 +489,42 @@ mod tests { /// The index spells out the handle rather than joining for it, so a /// rename has to reach the rows that already named the old one. + #[tokio::test] + async fn finds_a_mod_by_who_is_credited_and_for_what() { + let db = indexed_db(&[("Master Quest", "Hard", Game::Pm64, "lily.test")]).await; + let wrymouth = Did::new("did:plc:wrymouth"); + actor::upsert(&db, &wrymouth, &Handle::new("wrymouth.test").unwrap()) + .await + .unwrap(); + let did = Did::new("did:plc:author0"); + let rkey = Rkey::from_verified("r0"); + let mut listing = ModListing::load_local(&db, &did, &rkey) + .await + .unwrap() + .unwrap(); + listing.credits = vec![ + Credit { + did: Some(wrymouth), + name: String::new(), + role: "Composer".to_string(), + }, + Credit { + did: None, + name: "Quackles".to_string(), + role: String::new(), + }, + ]; + listing.save_local(&db, &did, &rkey).await.unwrap(); + + for query in ["wrymouth", "composer", "quackles"] { + assert_eq!( + titles(&search(&db, query).await.unwrap()), + ["Master Quest"], + "{query}" + ); + } + } + #[tokio::test] async fn renaming_an_author_moves_their_mods_to_the_new_handle() { let db = indexed_db(&[("Master Quest", "A harder mod", Game::Pm64, "lily.test")]).await; diff --git a/tests/test_actor_profile.py b/tests/test_actor_profile.py index a409ae9..5e1bd85 100644 --- a/tests/test_actor_profile.py +++ b/tests/test_actor_profile.py @@ -72,11 +72,13 @@ def test_a_display_name_is_shown_instead_of_the_handle_on_cards_and_mod_pages( set_display_name(actor, "Wrymouth") actor.page.goto(f"{actor.app_url}/{actor.handle}/master-quest") - expect(actor.page.locator(".mod-author-name")).to_have_text("Wrymouth") - expect(actor.page.locator(".mod-author-handle")).to_have_text(f"@{actor.handle}") + credit = actor.page.locator(".mod-credit-name") + expect(credit).to_have_text("Wrymouth") + expect(credit).to_have_attribute("href", f"/{actor.handle}") + expect(actor.page.locator(".mod-credit-handle")).to_have_text(f"@{actor.handle}") actor.page.goto(f"{actor.app_url}/{actor.handle}") - expect(actor.page.locator(".mod-card-byline .byline-name")).to_have_text("Wrymouth") + expect(actor.page.locator(".mod-card-byline .byline-names")).to_have_text("Wrymouth") def test_a_user_can_be_found_by_display_name(actor: Actor): diff --git a/tests/test_mod_listing.py b/tests/test_mod_listing.py index 769c153..a4f6f4f 100644 --- a/tests/test_mod_listing.py +++ b/tests/test_mod_listing.py @@ -85,6 +85,70 @@ def test_details_render_as_markdown_with_no_html_of_their_own(actor: Actor): expect(actor.page.locator(".mod-card-description")).to_have_text("A very good mod") +def test_credits_name_the_makers_by_handle_on_the_mod_page_and_its_card(actor: Actor, other_actor: Actor): + actor.login() + actor.create_mod("test-mod") + + # A new mod starts out crediting its owner, and that's all the page shows + # of whose it is. + [record] = actor.records(COLLECTION) + assert [credit["did"] for credit in record["value"]["credits"]] == [actor.did] + expect(actor.page.locator(".mod-credit")).to_have_count(1) + expect(actor.page.locator(".mod-author")).to_have_count(0) + + actor.page.goto(f"{actor.app_url}/{actor.handle}/test-mod/settings") + actor.page.get_by_role("button", name="Add person").click() + actor.page.locator("credits-input .item").nth(1).locator(".person input").fill(f"@{other_actor.handle}") + actor.page.locator("credits-input .item").nth(1).locator(".role").fill("Music") + actor.page.locator("credits-input .item").nth(0).locator(".role").fill("Everything else") + actor.page.get_by_role("button", name="Save").click() + expect(actor.page).to_have_url(f"{actor.app_url}/{actor.handle}/test-mod") + + credits = actor.page.locator(".mod-credit") + expect(credits).to_have_count(2) + expect(credits.nth(0)).to_contain_text("Everything else") + expect(credits.nth(1).get_by_role("link")).to_have_attribute("href", f"/{other_actor.handle}") + expect(credits.nth(1)).to_contain_text("Music") + + expect(actor.page.locator(".mod-game")).to_have_text("For Paper Mario") + + [record] = actor.records(COLLECTION) + assert record["value"]["credits"][1] == {"did": other_actor.did, "role": "Music"} + + actor.page.goto(f"{actor.app_url}/{actor.handle}") + expect(actor.page.locator(".mod-card-byline .byline-names")).to_have_text( + f"{actor.handle}, {other_actor.handle}" + ) + # Everyone's avatar, stacked, rather than the first credit's alone. + expect(actor.page.locator(".mod-card-byline .byline-avatar")).to_have_count(2) + + # Credits are searchable by who and for what. + actor.page.goto(f"{actor.app_url}/search?q=music") + expect(actor.page.get_by_text("Test Mod").first).to_be_visible() + + +def test_credits_refuse_what_is_not_an_account_or_leaves_out_the_owner(actor: Actor, other_actor: Actor): + actor.login() + actor.create_mod("test-mod") + actor.page.goto(f"{actor.app_url}/{actor.handle}/test-mod/settings") + person = actor.page.locator("credits-input .item").nth(0).locator(".person input") + save = actor.page.get_by_role("button", name="Save") + + person.fill("nobody.test") + save.click() + expect(actor.page.get_by_text("No account has the handle nobody.test").first).to_be_visible() + # What was typed comes back to fix. + expect(actor.page.locator("credits-input .person input").first).to_have_value("nobody.test") + + actor.page.locator("credits-input .person input").first.fill("not a handle") + save.click() + expect(actor.page.get_by_text("isn't a handle").first).to_be_visible() + + actor.page.locator("credits-input .person input").first.fill(other_actor.handle) + save.click() + expect(actor.page.get_by_text("Credit yourself too").first).to_be_visible() + + def test_sharing_a_mod_publishes_it_with_its_image_and_first_version(actor: Actor): actor.login() actor.create_mod("test-mod")