use crate::land::FLAG_SVG;
use crate::nav::CyberiaNav;
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
const FOUND_KEY: &str = "cyberia_found_cities";
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CityCard {
pub id: String,
pub name: String,
pub region: String,
pub blurb: String,
pub ha: f64,
pub plots: u32,
pub status: String, pub href: String,
}
fn seed_cities() -> Vec<CityCard> {
vec![CityCard {
id: "cyber-valley".into(),
name: "Cyber Valley".into(),
region: "Gesing Β· Bali Β· Indonesia".into(),
blurb: "Phase 0 land map β 126 plots, hard-force fleets, intents.".into(),
ha: 37.0,
plots: 126,
status: "live".into(),
href: "/map".into(),
}]
}
pub fn load_found() -> Vec<CityCard> {
web_sys::window()
.and_then(|w| w.local_storage().ok().flatten())
.and_then(|ls| ls.get_item(FOUND_KEY).ok().flatten())
.and_then(|raw| serde_json::from_str(&raw).ok())
.unwrap_or_default()
}
fn save_found(list: &[CityCard]) {
if let Some(ls) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) {
if let Ok(raw) = serde_json::to_string(list) {
let _ = ls.set_item(FOUND_KEY, &raw);
}
}
}
fn slugify(name: &str) -> String {
let s: String = name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect();
let mut out = String::new();
for ch in s.chars() {
if ch == '-' {
if !out.ends_with('-') && !out.is_empty() {
out.push(ch);
}
} else {
out.push(ch);
}
}
out.trim_matches('-').to_string()
}
#[component]
pub fn CitiesPage() -> impl IntoView {
let found = RwSignal::new(load_found());
let sheet_open = RwSignal::new(false);
let draft_name = RwSignal::new(String::new());
let draft_region = RwSignal::new(String::new());
let draft_blurb = RwSignal::new(String::new());
let err = RwSignal::new(None::<String>);
Effect::new(move |_| {
document().set_title("Cyberia β cities");
});
let catalog = move || {
let mut list = seed_cities();
list.extend(found.get());
list
};
view! {
<div class="page-shell cities-shell">
<div class="site-chrome cyberia-chrome">
<div class="chrome-inner">
<div class="header-row1">
<div class="logo-zone">
<h1 class="logo">
<a href="/cities" class="brand-flag" title="cities" inner_html=FLAG_SVG></a>
<span style="color: var(--cyber-green);">"cyber"</span>
<span style="color: var(--cyber-green); margin: 0 1px;">"β’"</span>
<span style="color: #fff;">"ia"</span>
</h1>
</div>
<div class="cyberia-phase-pill">
<span class="phase-dot"></span>
{move || format!("{} CITIES", catalog().len())}
</div>
<CyberiaNav active="cities" />
</div>
</div>
</div>
<div class="cities-stage">
<div class="cities-hero">
<div>
<div class="cities-kicker">"CATALOG"</div>
<h2 class="cities-title">"Cities of Cyberia"</h2>
<p class="cities-lead">
"Land-first network cities. Open a live map, or found a new one."
</p>
</div>
<a class="cta-btn cta-lease cta-lg" href="/studio" style="text-decoration:none;">
<span class="cta-copy">
<span class="cta-title">"SIGNAL STUDIO"</span>
<span class="cta-sub">"words Β· links Β· signals"</span>
</span>
</a>
</div>
<div class="cities-grid">
{move || catalog().into_iter().enumerate().map(|(i, c)| {
let live = c.status == "live";
let href = c.href.clone();
let name = c.name.clone();
let region = c.region.clone();
let blurb = c.blurb.clone();
let ha = c.ha;
let plots = c.plots;
let status = c.status.to_uppercase();
let rank = i + 1;
view! {
<a class=if live { "city-card live" } else { "city-card founding" } href=href>
<div class="city-card-top">
<span class="city-rank">{format!("#{rank:02}")}</span>
<span class=if live { "city-status live" } else { "city-status founding" }>
{status}
</span>
</div>
<div class="city-name">{name}</div>
<div class="city-region">{region}</div>
<p class="city-blurb">{blurb}</p>
<div class="city-meta">
<span>{if ha > 0.0 { format!("{ha:.0} ha") } else { "β ha".into() }}</span>
<span>{if plots > 0 { format!("{plots} plots") } else { "plots TBD".into() }}</span>
<span class="city-open">{if live { "OPEN MAP β" } else { "FOUNDING β" }}</span>
</div>
</a>
}
}).collect_view()}
<button class="city-card ghost" on:click=move |_| {
err.set(None);
sheet_open.set(true);
}>
<div class="city-card-top">
<span class="city-rank">"+"</span>
<span class="city-status founding">"EMPTY"</span>
</div>
<div class="city-name">"Found a city"</div>
<div class="city-region">"next cyberia node"</div>
<p class="city-blurb">
"Claim a region, seed the catalog. Map + fleets land later."
</p>
<div class="city-meta">
<span>"β ha"</span>
<span>"plots TBD"</span>
<span class="city-open">"FOUND CITY β"</span>
</div>
</button>
</div>
</div>
{move || sheet_open.get().then(|| view! {
<div class="cyberia-sheet-backdrop" on:click=move |_| sheet_open.set(false)>
<div class="cyberia-sheet" on:click=move |ev| ev.stop_propagation()>
<div class="sheet-h">
<span class="panel-kicker">"FOUND CITY"</span>
<button class="sheet-x" on:click=move |_| sheet_open.set(false)>"β"</button>
</div>
<p class="sheet-note">
"Soft3 local catalog β no closed backend yet. City appears in your browser; land map ships later."
</p>
<label class="found-label">"NAME"</label>
<input
class="found-input"
type="text"
placeholder="e.g. Night Harbor"
prop:value=move || draft_name.get()
on:input=move |ev| {
let v = event_target_value(&ev);
draft_name.set(v);
}
/>
<label class="found-label">"REGION"</label>
<input
class="found-input"
type="text"
placeholder="e.g. YucatΓ‘n Β· Mexico"
prop:value=move || draft_region.get()
on:input=move |ev| {
draft_region.set(event_target_value(&ev));
}
/>
<label class="found-label">"BLURB"</label>
<input
class="found-input"
type="text"
placeholder="one line about the city"
prop:value=move || draft_blurb.get()
on:input=move |ev| {
draft_blurb.set(event_target_value(&ev));
}
/>
{move || err.get().map(|e| view! {
<div class="found-err">{e}</div>
})}
<button
class="intent-commit found"
on:click=move |_| {
let name = draft_name.get().trim().to_string();
if name.len() < 2 {
err.set(Some("name too short".into()));
return;
}
let mut id = slugify(&name);
if id.is_empty() {
err.set(Some("invalid name".into()));
return;
}
if id == "cyber-valley" {
err.set(Some("Cyber Valley already exists".into()));
return;
}
let mut list = found.get_untracked();
if list.iter().any(|c| c.id == id) || seed_cities().iter().any(|c| c.id == id) {
id = format!("{id}-{}", list.len() + 2);
}
let region = {
let r = draft_region.get().trim().to_string();
if r.is_empty() { "unspecified region".into() } else { r }
};
let blurb = {
let b = draft_blurb.get().trim().to_string();
if b.is_empty() {
"Founding city β catalog entry only for now.".into()
} else {
b
}
};
let card = CityCard {
id: id.clone(),
name,
region,
blurb,
ha: 0.0,
plots: 0,
status: "founding".into(),
href: format!("/cities#{id}"),
};
list.push(card);
save_found(&list);
found.set(list);
draft_name.set(String::new());
draft_region.set(String::new());
draft_blurb.set(String::new());
err.set(None);
sheet_open.set(false);
}
>
"CONFIRM FOUND"
</button>
</div>
</div>
})}
<div class="search-dock cyberia-dock cities-dock">
<span class="dock-count">
{move || {
let n = catalog().len();
let f = found.get().len();
format!("{n} cities Β· {f} founded here Β· 1 live map")
}}
</span>
<button class="cta-btn cta-found cta-lg cta-bold dock-found" on:click=move |_| {
err.set(None);
sheet_open.set(true);
}>
<span class="cta-ico">"β"</span>
<span class="cta-copy">
<span class="cta-title">"FOUND CITY"</span>
<span class="cta-sub">"add to catalog"</span>
</span>
</button>
<a href="https://x.com/cyberiacap" target="_blank" rel="noopener" class="dock-credit">
"π΄ a "<span style="color: var(--cyber-green);">"cyberia"</span>" project"
</a>
</div>
</div>
}
}