crates/gunnar-ui-web/src/pages/my_storage.rs
//! **A person's room, read only** — a member's (the account's room) and a
//! provided Gunnar's, drawn on You → Account — and the old address
//! `/account/storage`, which goes on to the server's door (owner 2026-10-17).
//!
//! Every number comes from `GET /api/account/room`, which names the namespace
//! from the SESSION (`api::room` on the server): there is nothing in this page
//! a reader could change to see somebody else's. A row is drawn only when the
//! server reports a value — no cap is no row, never "unlimited" invented here.
use leptos::prelude::*;
use serde::Deserialize;
use crate::fmt;
use crate::pages::{now_ms, resolved};
use crate::ui::{Empty, Metric, Panel};
/// `GET /api/account/room`.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Room {
pub namespace: String,
pub used_bytes: u64,
pub cap_bytes: Option<u64>,
pub free_bytes: Option<u64>,
pub last_push_unix_ms: Option<i64>,
#[serde(default)]
pub repos: Vec<RepoRoom>,
/// The ACCOUNT's room, shown to its member: read, never bought.
#[serde(default)]
pub read_only: bool,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct RepoRoom {
pub repo: String,
pub stored_bytes: u64,
pub share_permille: Option<u32>,
}
/// ★ **A person's own room, on You → Account, read only** (owner
/// 2026-10-17: the pages that sold went, the view of what is used stays):
/// how much is used, and each repository's share. Nothing here sells; the
/// Account page's one button does.
#[component]
pub fn OwnSpace() -> impl IntoView {
let room = LocalResource::new(|| async { crate::api::get::<Room>("/api/account/room").await });
move || resolved(room.get(), None, |r: Room| view! { <MyRoom r=r.clone()/> <PerRepository r=r/> })
}
/// Where `/account/storage` goes on to: the server's door in a metered build
/// (`purchase::storage_goes_to`), the Account page otherwise.
#[cfg(feature = "metered")]
fn goes_to(s: crate::app::Status) -> String {
crate::purchase::storage_goes_to(s.buy_url)
}
#[cfg(not(feature = "metered"))]
fn goes_to(_: crate::app::Status) -> String {
"/account".to_owned()
}
/// ★ **`/account/storage` is no page of its own** (owner 2026-10-17): it goes
/// on to the server's door — the same the Account page's button opens — or,
/// where the server names none (a member, a provided Gunnar, an open build),
/// to the Account page, which shows their room.
#[component]
pub fn StorageGoesOn() -> impl IntoView {
let session = crate::app::session();
let navigate = leptos_router::hooks::use_navigate();
Effect::new(move |_| {
let Some(s) = session.get() else { return };
let to = goes_to(s);
let replace = leptos_router::NavigateOptions { replace: true, ..Default::default() };
if to == "/account" {
navigate(&to, replace);
} else if let Some(w) = web_sys::window() {
// The door is the server's page: the browser loads it.
let _ = w.location().replace(&to);
}
});
view! { <crate::ui::Loading/> }
}
/// **The one line a member reads where the shop would be** — the server's
/// sentence (`/auth/status` `account_line`): "Your space comes from acme. Ask
/// acme for more room." Drawn for a member only; nothing at all otherwise.
#[component]
pub fn MemberLine() -> impl IntoView {
let session = crate::app::session();
move || {
session.get().filter(|s| s.is_member()).and_then(|s| s.account_line).map(|line| {
view! {
<p class="member-line" id="member-line" role="note">{line}</p>
}
})
}
}
/// **The one line a SOLD (provided) Gunnar shows where the shop would be** —
/// the server's sentence (`/auth/status` `shop_line`, naming the provider).
/// Nothing at all on a console whose shop is its own.
#[component]
pub fn ShopLine() -> impl IntoView {
let session = crate::app::session();
move || {
session.get().filter(|s| !s.sells_here()).and_then(|s| s.shop_line).map(|line| {
view! {
<p class="member-line" id="shop-line" role="note">{line}</p>
}
})
}
}
/// **A SOLD Gunnar's room, on You → Account**: the line and the room, read
/// only — nothing is bought here.
#[component]
pub fn ElsewhereSpace() -> impl IntoView {
let room = LocalResource::new(|| async { crate::api::get::<Room>("/api/account/room").await });
view! {
<ShopLine/>
{move || resolved(room.get(), None, |r: Room| view! { <MyRoom r=r/> })}
}
}
/// **A member's space, on You → Account**: the line and the ACCOUNT's room
/// (used, cap, free), read only, from the same `GET /api/account/room`.
#[component]
pub fn MemberSpace() -> impl IntoView {
let room = LocalResource::new(|| async { crate::api::get::<Room>("/api/account/room").await });
view! {
<MemberLine/>
{move || resolved(room.get(), None, |r: Room| view! { <MyRoom r=r/> })}
}
}
/// The words beside "Used": whose room it is, said as words (`in rickard`),
/// never the bare name glued to the size (test2 2026-10-12: "Used 0 Brickard").
pub fn used_unit(namespace: &str) -> String {
if namespace.is_empty() { String::new() } else { format!("in {namespace}") }
}
#[cfg(test)]
mod used_unit_tests {
#[test]
fn the_size_and_the_name_are_separate_words() {
assert_eq!(super::used_unit("rickard"), "in rickard");
assert_eq!(super::used_unit(""), "");
// The metric puts a space between the value and its unit.
let ui = include_str!("../ui.rs");
assert!(ui.contains(r#"view! { " " <small>{unit}</small> }"#), "a space before the unit");
}
}
#[component]
fn MyRoom(r: Room) -> impl IntoView {
let now = now_ms();
let metrics = view! {
<dl class="metrics">
<Metric label="Used" value=fmt::bytes(r.used_bytes) unit=used_unit(&r.namespace)/>
// Round 5.3 (owner 2026-10-08: the customer never reads a cap or
// the room left): the room this account holds, and what of it is
// used — "Room", never "Cap" or "Free".
{r.cap_bytes.map(|c| view! { <Metric label="Room" value=fmt::bytes(c)/> })}
{r.last_push_unix_ms.map(|at| view! { <Metric label="Last push" value=fmt::since(at, now)/> })}
</dl>
};
if r.read_only {
// A member reads the ACCOUNT's room, named as the account's.
view! { <Panel title=format!("{}'s room", r.namespace)>{metrics}</Panel> }.into_any()
} else {
view! { <Panel title="My room">{metrics}</Panel> }.into_any()
}
}
#[component]
fn PerRepository(r: Room) -> impl IntoView {
view! {
<Panel title="Per repository">
{if r.repos.is_empty() {
view! { <Empty title="Nothing stored yet" detail="Your repositories appear here once something has been pushed."/> }.into_any()
} else {
view! {
<div class="table-wrap">
<table>
<thead>
<tr>
<th scope="col">"Repository"</th>
<th scope="col">"Stored"</th>
<th scope="col">{if r.read_only { "Share of the room" } else { "Share of my room" }}</th>
</tr>
</thead>
<tbody>
{r.repos.iter().map(|row| {
let href = format!("/{}/{}", crate::api::segment(&r.namespace), crate::api::segment(&row.repo));
view! {
<tr>
<td class="mono"><a href=href>{row.repo.clone()}</a></td>
<td class="num">{fmt::bytes(row.stored_bytes)}</td>
<td class="num">{row.share_permille.map(crate::disks::percent).unwrap_or_default()}</td>
</tr>
}
}).collect_view()}
</tbody>
</table>
</div>
}
.into_any()
}}
</Panel>
}
}