crates/gunnar-ui-web/src/pages/clans.rs
//! **You → Clans** (owner, 2026-10-03/05): the person's clans and
//! invitations, and the clan verbs — make one, invite, answer, give room,
//! leave, and the admin's own.
//!
//! Every number and every refusal is gunnar's (`GET /api/account/clans`, and
//! each verb's answer through `api::clans`, signed as the person). What the
//! page decides — which controls a standing is drawn, and the words around
//! gunnar's numbers — is [`crate::clans`], where `cargo test` reaches it.
use leptos::prelude::*;
use crate::clans::{Clan, Clans, Offer};
use crate::ui::{FailBox, Hint, PageHeader};
/// One verb's POST, then the list again; a refusal goes to `failed` in
/// gunnar's words.
fn act(
url: String,
body: serde_json::Value,
list: LocalResource<Result<Clans, crate::api::Fail>>,
failed: RwSignal<Option<crate::api::Fail>>,
) {
leptos::task::spawn_local(async move {
match crate::api::post::<_, serde_json::Value>(&url, &body).await {
Ok(_) => {
failed.set(None);
list.refetch();
}
Err(fail) => failed.set(Some(fail)),
}
});
}
/// The GiB typed into a field, as the server is sent it (it says no to what
/// is not a number of GiB, and to what does not fit).
fn typed_gib(text: &str) -> serde_json::Value {
serde_json::json!(text.trim().parse::<f64>().unwrap_or(-1.0))
}
/// **A person with no room to give**: on a metered console, the words and where room is
/// bought (`purchase`, behind the seam); an open console sells nothing and says nothing.
#[cfg(feature = "metered")]
fn nothing_to_give_line() -> AnyView {
view! {
<p class="clan-nothing-to-give">{crate::purchase::NOTHING_TO_GIVE} " " <a href=crate::purchase::NOTHING_TO_RENEW_HREF>{crate::purchase::BUY_A_PLAN}</a></p>
}
.into_any()
}
#[cfg(not(feature = "metered"))]
fn nothing_to_give_line() -> AnyView {
().into_any()
}
#[component]
pub fn ClansPage() -> impl IntoView {
let session = crate::app::session();
let list = LocalResource::new(|| async { crate::api::get::<Clans>("/api/account/clans").await });
let failed = RwSignal::new(None::<crate::api::Fail>);
let name = RwSignal::new(String::new());
// A clan is made with nothing given; what one gives is set under More.
let create = move |_| {
let body = serde_json::json!({ "name": name.get_untracked().trim(), "contribution_gib": typed_gib("0") });
act("/api/account/clans".to_owned(), body, list, failed);
name.set(String::new());
};
view! {
<PageHeader title="Groups"/>
<Hint
line="A group is several accounts sharing one place for repositories; each member gives it part of its own room."
more="Its admin invites accounts and decides what is made in it: who may reach it, whether it is public, \
what is deleted. Members read, write and make private repositories there. The group's room is the \
sum of what its members give; a contribution goes down only as far as what the group stores, and \
leaving gives it back."/>
// gunnar's word about the account (`account_role`): a member makes no clan.
{move || session.get().filter(|s| !crate::clans::may_make_clan(s.account_role.as_deref())).map(|_| view! {
<crate::pages::my_storage::MemberLine/>
})}
<Show when=move || session.get().is_some_and(|s| crate::clans::may_make_clan(s.account_role.as_deref()))>
<section class="panel" id="clan-new">
<h3>"Create a group"</h3>
<div class="form-row">
<label class="field">{crate::clans::HINT_CLAN_NAME}
<input id="clan-name" placeholder=crate::clans::HINT_CLAN_NAME
prop:value=move || name.get()
on:input=move |ev| name.set(event_target_value(&ev))/>
</label>
</div>
<p><button class="btn primary" id="clan-create" on:click=create>"Create a group"</button></p>
</section>
</Show>
<div id="clan-fail">{move || failed.get().map(|fail| view! { <FailBox fail=fail/> })}</div>
// Round 2: repositories offered to the reader's own account.
{move || match list.get() {
Some(Ok(all)) if !all.offers.is_empty() => view! {
<section class="panel" id="clan-offers-to-me">
<h3>"Offered to your account"</h3>
<ul>{all.offers.into_iter().map(|offer| view! { <OfferToMe offer=offer list=list failed=failed/> }).collect_view()}</ul>
</section>
}.into_any(),
_ => ().into_any(),
}}
{move || match list.get() {
None => ().into_any(),
Some(Err(fail)) => view! { <FailBox fail=fail/> }.into_any(),
Some(Ok(all)) if all.clans.is_empty() => view! {
<p class="hint" id="clans-none">"You are in no group, and invited to none."</p>
}.into_any(),
Some(Ok(all)) => all
.clans
.into_iter()
.map(|clan| view! { <ClanCard clan=clan list=list failed=failed/> })
.collect_view()
.into_any(),
}}
}
}
/// One repository offered to the reader's account: take it (gunnar moves it
/// into the account, if the account's room holds it) or decline.
#[component]
fn OfferToMe(
offer: Offer,
list: LocalResource<Result<Clans, crate::api::Fail>>,
failed: RwSignal<Option<crate::api::Fail>>,
) -> impl IntoView {
let url = StoredValue::new(format!(
"/api/account/clans/{}/offers/{}",
crate::api::segment(&offer.clan),
crate::api::segment(&offer.repo)
));
view! {
<li class="clan-offer-to-me" data-repo=format!("{}/{}", offer.clan, offer.repo)>
<span>{crate::clans::offer_line(&offer)}</span>
" "
<button class="btn primary clan-offer-take"
on:click=move |_| act(url.get_value(), serde_json::json!({ "accept": true }), list, failed)>"Take it"</button>
" "
<button class="btn clan-offer-decline"
on:click=move |_| act(url.get_value(), serde_json::json!({ "accept": false }), list, failed)>"Decline"</button>
</li>
}
}
/// One clan, drawn for the standing gunnar reported.
#[component]
fn ClanCard(
clan: Clan,
list: LocalResource<Result<Clans, crate::api::Fail>>,
failed: RwSignal<Option<crate::api::Fail>>,
) -> impl IntoView {
let can = crate::clans::controls(&clan.role);
let base = format!("/api/account/clans/{}", crate::api::segment(&clan.name));
let path = StoredValue::new(base);
let url = move |verb: &str| format!("{}/{verb}", path.get_value());
let give = RwSignal::new(String::new());
let accept_give = RwSignal::new("0".to_owned());
let who = RwSignal::new(String::new());
let move_repo = RwSignal::new(String::new());
let move_to = RwSignal::new(String::new());
let dissolve_typed = RwSignal::new(String::new());
let clan_name = StoredValue::new(clan.name.clone());
let you = crate::app::session().get_untracked().map(|s| s.account.unwrap_or(s.user)).unwrap_or_default();
let members = clan.members.iter().map(|m| view! { <li class="clan-member" data-id=m.id.clone()><span>{crate::clans::member_line(m)}</span></li> }).collect_view();
// The admin's own presses on each member, under More: drawn disabled, with
// the reason, where gunnar would refuse (`clans::member_buttons`).
let member_presses = clan
.members
.iter()
.map(|m| {
let buttons = crate::clans::member_buttons(&clan, m, &you);
let (remove_id, admin_id) = (m.id.clone(), m.id.clone());
let admin = m.admin;
view! {
<li class="clan-member-presses" data-id=m.id.clone()>
<span>{m.id.clone()}</span>
" "
<button class="btn clan-remove" disabled=buttons.remove.is_err() title=buttons.remove.err().unwrap_or_default()
on:click=move |_| act(url("remove"), serde_json::json!({ "account": remove_id.clone() }), list, failed)>
"Remove"</button>
" "
<button class="btn clan-admin" data-admin=admin.to_string() disabled=buttons.toggle_admin.is_err() title=buttons.toggle_admin.err().unwrap_or_default()
on:click=move |_| act(url("admin"), serde_json::json!({ "account": admin_id.clone(), "admin": !admin }), list, failed)>
{if admin { "Make a member" } else { "Make admin" }}</button>
{buttons.toggle_admin.err().or(buttons.remove.err()).map(|why| view! { " " <span class="hint clan-why">{why}</span> })}
</li>
}
})
.collect_view();
let invites = clan
.invites
.iter()
.map(|i| {
let id = i.id.clone();
let withdraw_id = i.id.clone();
view! {
<li class="clan-invite" data-id=id>
{format!("{} · invited by {}", i.id, i.invited_by)}
{can.administer.then(move || view! {
" "
<button class="btn clan-withdraw" on:click=move |_| act(url("decline"), serde_json::json!({ "account": withdraw_id.clone() }), list, failed)>
"Withdraw"</button>
})}
</li>
}
})
.collect_view();
let offers = clan
.offers
.iter()
.map(|o| {
let line = crate::clans::offer_line(o);
let repo = o.repo.clone();
view! {
<li class="clan-offer" data-repo=o.repo.clone()>
{line}
{can.administer.then(move || view! {
" "
<button class="btn clan-offer-withdraw"
on:click=move |_| act(
format!("/api/account/clans/{}/offers/{}", crate::api::segment(&clan_name.get_value()), crate::api::segment(&repo)),
serde_json::json!({ "accept": false }),
list,
failed,
)>"Withdraw"</button>
})}
</li>
}
})
.collect_view();
view! {
<section class="panel clan" id=format!("clan-{}", clan.name) data-role=clan.role.clone()>
<h3><a href=format!("/{}", crate::api::segment(&clan.name))>{clan.name.clone()}</a></h3>
<p class="clan-role">{crate::clans::role_line(&clan.role)}</p>
<p class="clan-room">{crate::clans::room_line(&clan)}</p>
{crate::clans::floor_line(&clan).map(|line| view! { <p class="clan-floor">{line}</p> })}
{crate::clans::nothing_to_give(&clan).then(nothing_to_give_line)}
{crate::clans::left_line(&clan).map(|line| view! { <p class="clan-left">{line}</p> })}
<ul class="clan-members">{members}</ul>
<ul class="clan-invites">{invites}</ul>
<ul class="clan-offers">{offers}</ul>
{can.answer.then(|| view! {
<div class="form-row">
<label class="field">"You give (GiB)"
<input class="clan-accept-give" type="number" min="0" step="any"
prop:value=move || accept_give.get()
on:input=move |ev| accept_give.set(event_target_value(&ev))/>
</label>
</div>
<p>
<button class="btn primary clan-accept"
on:click=move |_| act(url("accept"), serde_json::json!({ "contribution_gib": typed_gib(&accept_give.get_untracked()) }), list, failed)>
"Join"</button>
" "
<button class="btn clan-decline" on:click=move |_| act(url("decline"), serde_json::json!({}), list, failed)>
"Decline"</button>
</p>
})}
// Shown first for a clan the person administers: invite someone.
{can.administer.then(|| view! {
<div class="form-row">
<label class="field">"Invite someone"
<input class="clan-invite-who" placeholder=crate::clans::HINT_INVITE
prop:value=move || who.get()
on:input=move |ev| who.set(event_target_value(&ev))/>
</label>
</div>
<p>
<button class="btn primary clan-invite-send"
on:click=move |_| act(url("invite"), serde_json::json!({ "account": who.get_untracked().trim() }), list, failed)>
"Invite"</button>
</p>
})}
// Everything else, asked for (owner 2026-10-14: "clan seems advanced").
{(can.give_and_leave || can.administer).then(|| view! {
<details class="more clan-more">
<summary>"More"</summary>
{can.give_and_leave.then(|| view! {
<div class="form-row">
<label class="field">"You give (GiB)"
<input class="clan-give" type="number" min="0" step="any"
prop:value=move || give.get()
on:input=move |ev| give.set(event_target_value(&ev))/>
</label>
</div>
<p>
<button class="btn clan-give-set"
on:click=move |_| act(url("contribution"), serde_json::json!({ "contribution_gib": typed_gib(&give.get_untracked()) }), list, failed)>
"Set what you give"</button>
" "
<button class="btn clan-leave" on:click=move |_| act(url("leave"), serde_json::json!({}), list, failed)>
"Leave"</button>
</p>
})}
{can.administer.then(|| view! {
<ul class="clan-member-list">{member_presses}</ul>
<div class="form-row">
<label class="field">"Move a repository out"
<input class="clan-move-repo" placeholder=crate::clans::HINT_MOVE_REPO
prop:value=move || move_repo.get()
on:input=move |ev| move_repo.set(event_target_value(&ev))/>
</label>
<label class="field">"into the account"
<input class="clan-move-to" placeholder=crate::clans::HINT_MOVE_TO
prop:value=move || move_to.get()
on:input=move |ev| move_to.set(event_target_value(&ev))/>
</label>
</div>
<p>
<button class="btn clan-move-offer"
on:click=move |_| act(
url("offer"),
serde_json::json!({ "repo": move_repo.get_untracked().trim(), "account": move_to.get_untracked().trim() }),
list,
failed,
)>"Offer it"</button>
</p>
// Apart, and only with the clan's name typed.
<div class="consequence clan-dissolve-box" role="group">
<p>"Dissolve this group. Type " <code>{clan_name.get_value()}</code> " to confirm."</p>
<p>
<input class="clan-dissolve-confirm" type="text" autocomplete="off"
prop:value=move || dissolve_typed.get()
on:input=move |ev| dissolve_typed.set(event_target_value(&ev))/>
" "
<button class="btn danger clan-dissolve"
disabled=move || !crate::clans::may_dissolve(&dissolve_typed.get(), &clan_name.get_value())
on:click=move |_| act(url("dissolve"), serde_json::json!({}), list, failed)>
"Dissolve"</button>
</p>
</div>
})}
</details>
})}
</section>
}
}