mirror of
https://git.alemi.dev/fedicharter.git
synced 2024-11-25 09:34:47 +01:00
feat: added leptos web frontend poc
This commit is contained in:
parent
ac20d43b20
commit
02bfc8afc7
3 changed files with 234 additions and 0 deletions
12
Cargo.toml
12
Cargo.toml
|
@ -16,6 +16,11 @@ name = "fedicharter-cli"
|
||||||
path = "src/cli/main.rs"
|
path = "src/cli/main.rs"
|
||||||
required-features = ["cli"]
|
required-features = ["cli"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "fedicharter-web"
|
||||||
|
path = "src/web/main.rs"
|
||||||
|
required-features = ["web"]
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
@ -37,8 +42,15 @@ reqwest = { version = "0.11.20", features = ["json"], optional = true }
|
||||||
sea-orm = { version = "0.12.3", features = ["runtime-tokio-native-tls", "sqlx-sqlite", "sqlx-postgres"], optional = true }
|
sea-orm = { version = "0.12.3", features = ["runtime-tokio-native-tls", "sqlx-sqlite", "sqlx-postgres"], optional = true }
|
||||||
clap = { version = "4.4.6", features = ["derive"], optional = true }
|
clap = { version = "4.4.6", features = ["derive"], optional = true }
|
||||||
|
|
||||||
|
# wasm and web stuff
|
||||||
|
console_error_panic_hook = { version = "0.1.7", optional = true }
|
||||||
|
console_log = { version = "1.0.0", optional = true }
|
||||||
|
leptos = { version = "0.5.1", features = ["csr"], optional = true }
|
||||||
|
reqwasm = { version = "0.5.0", optional = true }
|
||||||
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["web", "cli"]
|
default = ["web", "cli"]
|
||||||
db = ["dep:tokio", "dep:sea-orm", "dep:reqwest"]
|
db = ["dep:tokio", "dep:sea-orm", "dep:reqwest"]
|
||||||
|
web = ["dep:console_error_panic_hook", "dep:console_log", "dep:leptos", "dep:reqwasm"]
|
||||||
cli = ["db", "dep:axum", "dep:tracing-subscriber", "dep:clap"]
|
cli = ["db", "dep:axum", "dep:tracing-subscriber", "dep:clap"]
|
||||||
|
|
68
index.html
Normal file
68
index.html
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>akkoma bubble network</title>
|
||||||
|
<script
|
||||||
|
type="text/javascript"
|
||||||
|
src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"
|
||||||
|
></script>
|
||||||
|
<style type="text/css">
|
||||||
|
body {
|
||||||
|
background: #111111;
|
||||||
|
}
|
||||||
|
#netgraph {
|
||||||
|
width: 100%;
|
||||||
|
height: 98vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="netgraph">
|
||||||
|
<h3 style="text-align: center;" id="loader"><font color="#BF616A">crunching fedi network data, be patient...</font></h3>
|
||||||
|
</div>
|
||||||
|
<script type="text/javascript">
|
||||||
|
let nodes_array = []
|
||||||
|
let edges_array = []
|
||||||
|
|
||||||
|
let domain;
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const domainParam = urlParams.get('domain');
|
||||||
|
if (domainParam) {
|
||||||
|
domain = domainParam;
|
||||||
|
console.log("scanning domain " + domain);
|
||||||
|
} else {
|
||||||
|
domain = window.prompt("starting instance for charting", "ihatebeinga.live");
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`https://api.alemi.dev/akkoma/bubble/crawl?domain=${domain}`)
|
||||||
|
.then((res) => res.json().then((graph) => {
|
||||||
|
const palette = [
|
||||||
|
'#81A1C1',
|
||||||
|
'#5B6EA3',
|
||||||
|
'#7468B0',
|
||||||
|
'#84508C',
|
||||||
|
'#AF875F',
|
||||||
|
'#EBCB8B',
|
||||||
|
'#2E8757',
|
||||||
|
'#05979A',
|
||||||
|
]
|
||||||
|
for (const i in graph[0]) {
|
||||||
|
if (graph[0][i].title === domain) { graph[0][i].color = '#BF616A' }
|
||||||
|
else { graph[0][i].color = palette[Math.floor(Math.random() * palette.length)] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = new vis.DataSet(graph[0]);
|
||||||
|
const edges = new vis.DataSet(graph[1]);
|
||||||
|
|
||||||
|
// create a network
|
||||||
|
const container = document.getElementById("netgraph");
|
||||||
|
const data = {
|
||||||
|
nodes: nodes,
|
||||||
|
edges: edges,
|
||||||
|
};
|
||||||
|
const options = { edges: { dashes: true, arrows: 'to' }, nodes: { color: "#bf616a", shape: "box", scaling: { min: 1, max: 500, label: { enabled: true, min: 14, max: 56 }}}};
|
||||||
|
const network = new vis.Network(container, data, options);
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
154
src/web/main.rs
Normal file
154
src/web/main.rs
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use leptos::{*, error::Result};
|
||||||
|
use fedicharter::nodeinfo::model::NodeInfoOwned;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
_ = console_log::init_with_level(log::Level::Debug);
|
||||||
|
console_error_panic_hook::set_once();
|
||||||
|
mount_to(
|
||||||
|
document().get_element_by_id("#app")
|
||||||
|
.expect("no #app element")
|
||||||
|
.unchecked_into(), // lmao i found it like this on leptops examples
|
||||||
|
|| view! { <App/> }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_node_info(domain: String) -> Result<(Arc<NodeInfoOwned>, String)> {
|
||||||
|
Ok((
|
||||||
|
Arc::new(
|
||||||
|
reqwasm::http::Request::get(
|
||||||
|
&format!("https://api.alemi.dev/akkoma/bubble/instance?domain={}", &domain)
|
||||||
|
).send()
|
||||||
|
.await?
|
||||||
|
.json::<NodeInfoOwned>()
|
||||||
|
.await?
|
||||||
|
),
|
||||||
|
domain,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn App() -> impl IntoView {
|
||||||
|
// let (live, live_tx) = create_signal("://".to_string());
|
||||||
|
let (submitted, submitted_tx) = create_signal("social.alemi.dev".to_string());
|
||||||
|
|
||||||
|
let nodeinfo = create_local_resource(move || submitted.get(), fetch_node_info);
|
||||||
|
|
||||||
|
let fallback = move |errors: RwSignal<Errors>| {
|
||||||
|
let error_list = move || {
|
||||||
|
errors.with(|errors| {
|
||||||
|
errors
|
||||||
|
.iter()
|
||||||
|
.map(|(_, e)| view! { <li>{e.to_string()}</li> })
|
||||||
|
.collect_view()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="error">
|
||||||
|
<h2>"Error"</h2>
|
||||||
|
<ul>{error_list}</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// the renderer can handle Option<_> and Result<_> states
|
||||||
|
// by displaying nothing for None if the resource is still loading
|
||||||
|
// and by using the ErrorBoundary fallback to catch Err(_)
|
||||||
|
// so we'll just use `.and_then()` to map over the happy path
|
||||||
|
let instance_view = move || {
|
||||||
|
nodeinfo.and_then(|(data, domain)| {
|
||||||
|
view! { <InstanceDetails domain=domain.clone() instance=data.clone() /> }
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
view ! {
|
||||||
|
<Searchbar submitted=submitted_tx placeholder="social.alemi.dev".to_string() />
|
||||||
|
<ErrorBoundary fallback>
|
||||||
|
<Transition fallback=move || {
|
||||||
|
view! { <div>"Loading (Suspense Fallback)..."</div> }
|
||||||
|
}>
|
||||||
|
<div>
|
||||||
|
{instance_view}
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</ErrorBoundary>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn Searchbar(
|
||||||
|
#[prop(optional)]
|
||||||
|
live: Option<WriteSignal<String>>,
|
||||||
|
#[prop(optional)]
|
||||||
|
submitted: Option<WriteSignal<String>>,
|
||||||
|
#[prop(default = "".to_string())]
|
||||||
|
placeholder: String,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let input_element: NodeRef<html::Input> = create_node_ref();
|
||||||
|
|
||||||
|
let on_submit = move |ev: web_sys::SubmitEvent| {
|
||||||
|
ev.prevent_default();
|
||||||
|
if let Some(tx) = submitted {
|
||||||
|
tx.set(
|
||||||
|
input_element.get()
|
||||||
|
.expect("no input field")
|
||||||
|
.value()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div>
|
||||||
|
<form on:submit=on_submit>
|
||||||
|
<input type="text"
|
||||||
|
on:input=move |ev| {
|
||||||
|
if let Some(tx) = live {
|
||||||
|
tx.set(event_target_value(&ev));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node_ref=input_element
|
||||||
|
prop:value=placeholder
|
||||||
|
/>
|
||||||
|
<input type="submit" value="Go"/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn InstanceDetails(
|
||||||
|
domain: String,
|
||||||
|
instance: Arc<NodeInfoOwned>,
|
||||||
|
) -> impl IntoView {
|
||||||
|
let title = instance.metadata.get("nodeName")
|
||||||
|
.map(|x| x.as_str().expect("name is not a string").to_string())
|
||||||
|
.unwrap_or(domain);
|
||||||
|
view! {
|
||||||
|
<div>
|
||||||
|
<h2>{ &title }</h2>
|
||||||
|
<h3>"["{instance.protocols.join(",")}"]~ " { &instance.software.name }<sup><a prop:href=instance.software.homepage.as_ref()>1</a></sup> " v" { &instance.software.version }<sup><a prop:href=instance.software.repository.as_ref()>src</a></sup></h3>
|
||||||
|
<ul>
|
||||||
|
<li>"users:"
|
||||||
|
<ul>
|
||||||
|
<li>"registered: " <b>{ instance.usage.users.total }</b></li>
|
||||||
|
<li>"active monthly: " <b>{ instance.usage.users.active_month }</b></li>
|
||||||
|
<li>"active half year: " <b>{ instance.usage.users.active_halfyear }</b></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>"posts: " <b>{ instance.usage.local_posts }</b></li>
|
||||||
|
<li>"comments: " <b>{ instance.usage.local_comments }</b></li>
|
||||||
|
<li>"open registration: " <b>{ instance.open_registrations }</b></li>
|
||||||
|
<li>"services:"
|
||||||
|
<ul>
|
||||||
|
<li>"rx: " <b>"["{ instance.services.inbound.join(", ") }"]"</b></li>
|
||||||
|
<li>"tx: " <b>"["{ instance.services.outbound.join(", ") }"]"</b></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>"metadata: " <pre>{ serde_json::to_string_pretty(&instance.metadata) }</pre></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue