Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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>
    }
}