karma-web: online status, user counts, join links, rising/chart/most-debated/milestones/reasons, filter+sort, expandable history, OG tags, JSON API
All checks were successful
ci / check (push) Successful in 46s
All checks were successful
ci / check (push) Successful in 46s
This commit is contained in:
parent
26496b5147
commit
6af92c1217
7 changed files with 714 additions and 101 deletions
|
|
@ -3,7 +3,7 @@ use std::path::PathBuf;
|
|||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use rustbot::i18n::Locale;
|
||||
use rustbot::karma_web::{parse_board, parse_events, parse_meta, render, Board};
|
||||
use rustbot::karma_web::{parse_board, parse_events, parse_meta, render, render_json, Board};
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
|
|
@ -40,9 +40,11 @@ fn main() {
|
|||
}
|
||||
let meta = p.with_file_name(format!("karma.{net}.meta.json"));
|
||||
if let Ok(mbody) = fs::read_to_string(&meta) {
|
||||
let (server, channels) = parse_meta(&mbody);
|
||||
board.server = server;
|
||||
board.channels = channels;
|
||||
let m = parse_meta(&mbody);
|
||||
board.server = m.server;
|
||||
board.webchat = m.webchat;
|
||||
board.seen = m.seen;
|
||||
board.channels = m.channels;
|
||||
}
|
||||
boards.push(board);
|
||||
}
|
||||
|
|
@ -58,12 +60,22 @@ fn main() {
|
|||
|
||||
match out {
|
||||
Some(path) => {
|
||||
let tmp = format!("{path}.tmp");
|
||||
if let Err(e) = fs::write(&tmp, &html).and_then(|_| fs::rename(&tmp, &path)) {
|
||||
if let Err(e) = write_atomic(&path, &html) {
|
||||
eprintln!("karma-web: could not write {path}: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
// emit the JSON API next to the page
|
||||
if let Some(dir) = PathBuf::from(&path).parent() {
|
||||
let json_path = dir.join("karma.json").to_string_lossy().into_owned();
|
||||
let _ = write_atomic(&json_path, &render_json(&boards, epoch));
|
||||
}
|
||||
}
|
||||
None => print!("{html}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_atomic(path: &str, body: &str) -> std::io::Result<()> {
|
||||
let tmp = format!("{path}.tmp");
|
||||
fs::write(&tmp, body)?;
|
||||
fs::rename(&tmp, path)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ pub struct Bot {
|
|||
modules: Vec<Box<dyn Module>>,
|
||||
routes: HashMap<&'static str, usize>,
|
||||
topics: HashMap<String, String>,
|
||||
users: HashMap<String, u32>,
|
||||
names_building: HashMap<String, u32>,
|
||||
}
|
||||
|
||||
impl Bot {
|
||||
|
|
@ -59,28 +61,44 @@ impl Bot {
|
|||
modules: mods,
|
||||
routes,
|
||||
topics: HashMap::new(),
|
||||
users: HashMap::new(),
|
||||
names_building: HashMap::new(),
|
||||
};
|
||||
bot.write_source_meta();
|
||||
bot
|
||||
}
|
||||
|
||||
/// Rewrite the per-network source sidecar the web board reads: server,
|
||||
/// the configured channels, and whatever topics we've learned so far.
|
||||
/// webchat base, a liveness timestamp, and per-channel topic + user count.
|
||||
fn write_source_meta(&self) {
|
||||
let channels: Vec<(String, Option<String>)> = self
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let channels: Vec<String> = self
|
||||
.config
|
||||
.channels
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let topic = self
|
||||
.topics
|
||||
.get(&c.to_lowercase())
|
||||
.filter(|t| !t.is_empty())
|
||||
.cloned();
|
||||
(c.clone(), topic)
|
||||
let key = c.to_lowercase();
|
||||
let mut obj = format!("{{\"name\":\"{}\"", crate::json::escape(c));
|
||||
if let Some(t) = self.topics.get(&key).filter(|t| !t.is_empty()) {
|
||||
obj.push_str(&format!(",\"topic\":\"{}\"", crate::json::escape(t)));
|
||||
}
|
||||
if let Some(u) = self.users.get(&key) {
|
||||
obj.push_str(&format!(",\"users\":{u}"));
|
||||
}
|
||||
obj.push('}');
|
||||
obj
|
||||
})
|
||||
.collect();
|
||||
modules::karma::write_meta(&self.config.name, &self.config.server, &channels);
|
||||
let body = format!(
|
||||
"{{\"server\":\"{}\",\"webchat\":\"{}\",\"seen\":{now},\"channels\":[{}]}}\n",
|
||||
crate::json::escape(&self.config.server),
|
||||
crate::json::escape(&self.config.webchat),
|
||||
channels.join(",")
|
||||
);
|
||||
modules::karma::write_meta(&self.config.name, &body);
|
||||
}
|
||||
|
||||
fn set_topic(&mut self, channel: Option<&str>, topic: Option<&str>) {
|
||||
|
|
@ -117,6 +135,8 @@ impl Bot {
|
|||
"PING" => {
|
||||
let token = msg.trailing().unwrap_or("").to_string();
|
||||
self.conn.pong(&token)?;
|
||||
// heartbeat: refresh the sidecar's liveness timestamp
|
||||
self.write_source_meta();
|
||||
}
|
||||
"CAP" => self.handle_cap(msg)?,
|
||||
"AUTHENTICATE" => self.handle_authenticate(msg)?,
|
||||
|
|
@ -140,6 +160,22 @@ impl Bot {
|
|||
"331" => self.set_topic(msg.param(1), Some("")),
|
||||
// live topic change: :who TOPIC <channel> :<topic>
|
||||
"TOPIC" => self.set_topic(msg.param(0), msg.trailing()),
|
||||
// RPL_NAMREPLY: <nick> <sym> <channel> :n1 n2 n3 (may repeat)
|
||||
"353" => {
|
||||
if let (Some(chan), Some(names)) = (msg.param(2), msg.trailing()) {
|
||||
let n = names.split_whitespace().count() as u32;
|
||||
*self.names_building.entry(chan.to_lowercase()).or_insert(0) += n;
|
||||
}
|
||||
}
|
||||
// RPL_ENDOFNAMES: finalize the count for this channel
|
||||
"366" => {
|
||||
if let Some(chan) = msg.param(1) {
|
||||
let key = chan.to_lowercase();
|
||||
let count = self.names_building.remove(&key).unwrap_or(0);
|
||||
self.users.insert(key, count);
|
||||
self.write_source_meta();
|
||||
}
|
||||
}
|
||||
"PRIVMSG" => self.handle_privmsg(msg)?,
|
||||
_ => {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -438,23 +438,9 @@ fn data_path(network: &str, ext: &str) -> PathBuf {
|
|||
|
||||
/// Record where a network's karma comes from (server + channels) so the web
|
||||
/// page can show its source. Best-effort — a failure just omits the line.
|
||||
pub fn write_meta(network: &str, server: &str, channels: &[(String, Option<String>)]) {
|
||||
let chans = channels
|
||||
.iter()
|
||||
.map(|(name, topic)| match topic {
|
||||
Some(t) => format!(
|
||||
"{{\"name\":\"{}\",\"topic\":\"{}\"}}",
|
||||
json::escape(name),
|
||||
json::escape(t)
|
||||
),
|
||||
None => format!("{{\"name\":\"{}\"}}", json::escape(name)),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let body = format!(
|
||||
"{{\"server\":\"{}\",\"channels\":[{chans}]}}\n",
|
||||
json::escape(server)
|
||||
);
|
||||
/// Atomically write the per-network source sidecar the web board reads. The
|
||||
/// caller (`Bot`) owns the JSON schema (server, webchat, seen, channels...).
|
||||
pub fn write_meta(network: &str, body: &str) {
|
||||
let path = data_path(network, "meta.json");
|
||||
let mut tmp = path.clone().into_os_string();
|
||||
tmp.push(".tmp");
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ pub struct Config {
|
|||
pub sasl_pass: Option<String>,
|
||||
pub locale: String,
|
||||
pub karma_url: String,
|
||||
pub webchat: String,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
|
|
@ -40,6 +41,7 @@ impl Default for Config {
|
|||
sasl_pass: None,
|
||||
locale: "en".to_string(),
|
||||
karma_url: String::new(),
|
||||
webchat: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -166,6 +168,7 @@ fn apply_kv(c: &mut Config, key: &str, value: &str, where_: &str) {
|
|||
"sasl_pass" => c.sasl_pass = (!value.is_empty()).then(|| value.to_string()),
|
||||
"locale" | "lang" => c.locale = value.to_string(),
|
||||
"karma_url" | "web_url" => c.karma_url = value.to_string(),
|
||||
"webchat" => c.webchat = value.to_string(),
|
||||
other => log(&format!("{where_}: unknown key '{other}'")),
|
||||
}
|
||||
}
|
||||
|
|
@ -229,6 +232,9 @@ fn apply_env(c: &mut Config) {
|
|||
if let Ok(v) = std::env::var("IRC_KARMA_URL") {
|
||||
c.karma_url = v;
|
||||
}
|
||||
if let Ok(v) = std::env::var("IRC_WEBCHAT") {
|
||||
c.webchat = v;
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_bool(value: &str) -> bool {
|
||||
|
|
|
|||
559
src/karma_web.rs
559
src/karma_web.rs
|
|
@ -21,6 +21,14 @@ pub struct Event {
|
|||
pub struct Channel {
|
||||
pub name: String,
|
||||
pub topic: Option<String>,
|
||||
pub users: Option<u32>,
|
||||
}
|
||||
|
||||
pub struct Meta {
|
||||
pub server: String,
|
||||
pub webchat: String,
|
||||
pub seen: u64,
|
||||
pub channels: Vec<Channel>,
|
||||
}
|
||||
|
||||
pub struct Board {
|
||||
|
|
@ -28,6 +36,8 @@ pub struct Board {
|
|||
pub rows: Vec<Row>,
|
||||
pub events: Vec<Event>,
|
||||
pub server: String,
|
||||
pub webchat: String,
|
||||
pub seen: u64,
|
||||
pub channels: Vec<Channel>,
|
||||
}
|
||||
|
||||
|
|
@ -54,6 +64,8 @@ pub fn parse_board(network: &str, body: &str) -> Board {
|
|||
rows,
|
||||
events: Vec::new(),
|
||||
server: String::new(),
|
||||
webchat: String::new(),
|
||||
seen: 0,
|
||||
channels: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -61,15 +73,17 @@ pub fn parse_board(network: &str, body: &str) -> Board {
|
|||
/// Parse the `karma.<net>.meta.json` sidecar the bot writes: where this
|
||||
/// network's karma comes from. Channels may be bare strings (old format) or
|
||||
/// `{"name","topic"}` objects.
|
||||
pub fn parse_meta(body: &str) -> (String, Vec<Channel>) {
|
||||
let Some(j) = Json::parse(body) else {
|
||||
return (String::new(), Vec::new());
|
||||
pub fn parse_meta(body: &str) -> Meta {
|
||||
let empty = || Meta {
|
||||
server: String::new(),
|
||||
webchat: String::new(),
|
||||
seen: 0,
|
||||
channels: Vec::new(),
|
||||
};
|
||||
let server = j
|
||||
.get("server")
|
||||
.and_then(Json::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let Some(j) = Json::parse(body) else {
|
||||
return empty();
|
||||
};
|
||||
let string = |k: &str| j.get(k).and_then(Json::as_str).unwrap_or("").to_string();
|
||||
let channels = match j.get("channels") {
|
||||
Some(Json::Arr(items)) => items
|
||||
.iter()
|
||||
|
|
@ -78,6 +92,7 @@ pub fn parse_meta(body: &str) -> (String, Vec<Channel>) {
|
|||
return (!name.is_empty()).then(|| Channel {
|
||||
name: name.to_string(),
|
||||
topic: None,
|
||||
users: None,
|
||||
});
|
||||
}
|
||||
let name = it.get("name").and_then(Json::as_str)?;
|
||||
|
|
@ -89,15 +104,22 @@ pub fn parse_meta(body: &str) -> (String, Vec<Channel>) {
|
|||
.and_then(Json::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string);
|
||||
let users = it.get("users").and_then(Json::as_f64).map(|n| n as u32);
|
||||
Some(Channel {
|
||||
name: name.to_string(),
|
||||
topic,
|
||||
users,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
(server, channels)
|
||||
Meta {
|
||||
server: string("server"),
|
||||
webchat: string("webchat"),
|
||||
seen: j.get("seen").and_then(Json::as_f64).unwrap_or(0.0) as u64,
|
||||
channels,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_events(body: &str) -> Vec<Event> {
|
||||
|
|
@ -328,6 +350,20 @@ struct Lex {
|
|||
give: &'static str,
|
||||
built: &'static str,
|
||||
updated: &'static str,
|
||||
online: &'static str,
|
||||
offline: &'static str,
|
||||
rising: &'static str,
|
||||
over_time: &'static str,
|
||||
most_debated: &'static str,
|
||||
milestones: &'static str,
|
||||
reasons_recent: &'static str,
|
||||
history: &'static str,
|
||||
reached: &'static str,
|
||||
bumps: &'static str,
|
||||
filter_ph: &'static str,
|
||||
sort_karma: &'static str,
|
||||
sort_name: &'static str,
|
||||
sort_active: &'static str,
|
||||
}
|
||||
|
||||
fn lex(l: Locale) -> Lex {
|
||||
|
|
@ -353,6 +389,20 @@ fn lex(l: Locale) -> Lex {
|
|||
give: "give karma by saying <code>nick++</code> in the channel",
|
||||
built: "built by",
|
||||
updated: "updated",
|
||||
online: "online",
|
||||
offline: "offline",
|
||||
rising: "rising this week",
|
||||
over_time: "karma over time",
|
||||
most_debated: "most debated",
|
||||
milestones: "milestones",
|
||||
reasons_recent: "recent reasons",
|
||||
history: "history",
|
||||
reached: "reached",
|
||||
bumps: "bumps",
|
||||
filter_ph: "filter…",
|
||||
sort_karma: "karma",
|
||||
sort_name: "name",
|
||||
sort_active: "active",
|
||||
},
|
||||
Locale::Es => Lex {
|
||||
tagline: "reputación ganada de a un <code>nick++</code>, en todos los canales.",
|
||||
|
|
@ -375,6 +425,20 @@ fn lex(l: Locale) -> Lex {
|
|||
give: "da karma escribiendo <code>nick++</code> en el canal",
|
||||
built: "hecho con",
|
||||
updated: "actualizado",
|
||||
online: "en línea",
|
||||
offline: "desconectado",
|
||||
rising: "en alza esta semana",
|
||||
over_time: "karma en el tiempo",
|
||||
most_debated: "más debatidos",
|
||||
milestones: "hitos",
|
||||
reasons_recent: "razones recientes",
|
||||
history: "historial",
|
||||
reached: "llegó a",
|
||||
bumps: "cambios",
|
||||
filter_ph: "filtrar…",
|
||||
sort_karma: "karma",
|
||||
sort_name: "nombre",
|
||||
sort_active: "activos",
|
||||
},
|
||||
Locale::Fr => Lex {
|
||||
tagline: "la réputation gagnée un <code>nick++</code> à la fois, sur les canaux.",
|
||||
|
|
@ -397,6 +461,20 @@ fn lex(l: Locale) -> Lex {
|
|||
give: "donne du karma en disant <code>nick++</code> dans le canal",
|
||||
built: "fait avec",
|
||||
updated: "mis à jour",
|
||||
online: "en ligne",
|
||||
offline: "hors ligne",
|
||||
rising: "en hausse cette semaine",
|
||||
over_time: "karma dans le temps",
|
||||
most_debated: "les plus débattus",
|
||||
milestones: "jalons",
|
||||
reasons_recent: "raisons récentes",
|
||||
history: "historique",
|
||||
reached: "a atteint",
|
||||
bumps: "changements",
|
||||
filter_ph: "filtrer…",
|
||||
sort_karma: "karma",
|
||||
sort_name: "nom",
|
||||
sort_active: "actifs",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -417,7 +495,10 @@ pub fn render(boards: &[Board], updated_epoch: u64, locale: Locale) -> String {
|
|||
.collect();
|
||||
let gifts = feed.len();
|
||||
|
||||
let mut s = String::from(HEAD);
|
||||
let now = updated_epoch;
|
||||
let mut s = HEAD
|
||||
.replace("__LANG__", locale.code())
|
||||
.replace("__OG__", &og_tags(boards, &lx));
|
||||
let multi = boards.iter().filter(|b| !b.rows.is_empty()).count() > 1;
|
||||
|
||||
s.push_str("<header class=\"hero fade\"><div class=\"prompt\">rubot@devtronic</div>");
|
||||
|
|
@ -443,20 +524,102 @@ pub fn render(boards: &[Board], updated_epoch: u64, locale: Locale) -> String {
|
|||
continue;
|
||||
}
|
||||
s.push_str("<section class=\"board fade\">");
|
||||
s.push_str(&source_line(b, &lx));
|
||||
s.push_str(&podium(b));
|
||||
s.push_str(&source_line(b, now, &lx));
|
||||
s.push_str(&podium(b, &lx));
|
||||
s.push_str(&ledger(b, &lx));
|
||||
s.push_str("</section>");
|
||||
}
|
||||
|
||||
s.push_str(&rising(&feed, now, &lx));
|
||||
s.push_str(&over_time(&feed, now, &lx));
|
||||
|
||||
s.push_str("<div class=\"band\">");
|
||||
s.push_str(&activity(&feed, multi, &lx));
|
||||
s.push_str(&givers_panel(&feed, &lx));
|
||||
s.push_str("</div>");
|
||||
|
||||
s.push_str("<div class=\"band3\">");
|
||||
s.push_str(&most_debated(&feed, &lx));
|
||||
s.push_str(&milestones(&feed, &lx));
|
||||
s.push_str(&reasons_wall(&feed, &lx));
|
||||
s.push_str("</div>");
|
||||
|
||||
finish(s, updated_epoch, locale, &lx)
|
||||
}
|
||||
|
||||
/// A machine-readable snapshot of the board(s) — served alongside the page as
|
||||
/// `karma.json` so other tools/bots can read the data.
|
||||
pub fn render_json(boards: &[Board], updated_epoch: u64) -> String {
|
||||
let nets: Vec<String> = boards
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let channels: Vec<String> = b
|
||||
.channels
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let mut o = format!("{{\"name\":\"{}\"", json::escape(&c.name));
|
||||
if let Some(t) = &c.topic {
|
||||
o.push_str(&format!(",\"topic\":\"{}\"", json::escape(t)));
|
||||
}
|
||||
if let Some(u) = c.users {
|
||||
o.push_str(&format!(",\"users\":{u}"));
|
||||
}
|
||||
o.push('}');
|
||||
o
|
||||
})
|
||||
.collect();
|
||||
let things: Vec<String> = b
|
||||
.rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let mut o = format!(
|
||||
"{{\"thing\":\"{}\",\"karma\":{}",
|
||||
json::escape(&r.thing),
|
||||
r.count
|
||||
);
|
||||
if let Some(w) = &r.reason {
|
||||
o.push_str(&format!(",\"reason\":\"{}\"", json::escape(w)));
|
||||
}
|
||||
if let Some(by) = &r.by {
|
||||
o.push_str(&format!(",\"by\":\"{}\"", json::escape(by)));
|
||||
}
|
||||
o.push('}');
|
||||
o
|
||||
})
|
||||
.collect();
|
||||
format!(
|
||||
"{{\"network\":\"{}\",\"server\":\"{}\",\"channels\":[{}],\"things\":[{}]}}",
|
||||
json::escape(&b.network),
|
||||
json::escape(&b.server),
|
||||
channels.join(","),
|
||||
things.join(",")
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
format!(
|
||||
"{{\"generated\":{updated_epoch},\"networks\":[{}]}}\n",
|
||||
nets.join(",")
|
||||
)
|
||||
}
|
||||
|
||||
fn og_tags(boards: &[Board], lx: &Lex) -> String {
|
||||
let things: usize = boards.iter().map(|b| b.rows.len()).sum();
|
||||
let top = boards
|
||||
.iter()
|
||||
.flat_map(|b| &b.rows)
|
||||
.max_by_key(|r| r.count)
|
||||
.map(|r| format!(" · {} {}", esc(&r.thing), signed(r.count)))
|
||||
.unwrap_or_default();
|
||||
let desc = format!("{things} {}{top}", lx.s_tracked);
|
||||
format!(
|
||||
"<meta property=\"og:type\" content=\"website\">\
|
||||
<meta property=\"og:title\" content=\"karma · devtronic\">\
|
||||
<meta property=\"og:description\" content=\"{desc}\">\
|
||||
<meta name=\"twitter:card\" content=\"summary\">\
|
||||
<meta name=\"description\" content=\"{desc}\">"
|
||||
)
|
||||
}
|
||||
|
||||
fn finish(mut s: String, epoch: u64, locale: Locale, lx: &Lex) -> String {
|
||||
s.push_str(&footer(epoch, lx));
|
||||
s.push_str("<script>var C=");
|
||||
|
|
@ -469,13 +632,29 @@ fn finish(mut s: String, epoch: u64, locale: Locale, lx: &Lex) -> String {
|
|||
}
|
||||
|
||||
/// "from #argentina · irc.libera.chat" — where this board's karma comes from.
|
||||
fn source_line(b: &Board, lx: &Lex) -> String {
|
||||
fn source_line(b: &Board, now: u64, lx: &Lex) -> String {
|
||||
let online = b.seen > 0 && now.saturating_sub(b.seen) < 300;
|
||||
let dot = format!(
|
||||
"<span class=\"dot {}\" title=\"{}\"></span>",
|
||||
if online { "on" } else { "off" },
|
||||
if online { lx.online } else { lx.offline }
|
||||
);
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if !b.channels.is_empty() {
|
||||
let chans = b
|
||||
.channels
|
||||
.iter()
|
||||
.map(|c| esc(&c.name))
|
||||
.map(|c| {
|
||||
let users = match c.users {
|
||||
Some(u) => format!(" <span class=\"users\">{u} {}</span>", lx.online),
|
||||
None => String::new(),
|
||||
};
|
||||
format!(
|
||||
"<a class=\"chan\" href=\"{}\" rel=\"noopener\" target=\"_blank\">{}</a>{users}",
|
||||
join_url(b, &c.name),
|
||||
esc(&c.name)
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
parts.push(format!("<span class=\"chans\">{chans}</span>"));
|
||||
|
|
@ -487,7 +666,7 @@ fn source_line(b: &Board, lx: &Lex) -> String {
|
|||
parts.push(format!("<span class=\"host\">{}</span>", esc(&b.network)));
|
||||
}
|
||||
let mut out = format!(
|
||||
"<div class=\"source\"><span class=\"lbl\">{}</span>{}</div>",
|
||||
"<div class=\"source\">{dot}<span class=\"lbl\">{}</span>{}</div>",
|
||||
lx.from,
|
||||
parts.join("<span class=\"sep\">·</span>")
|
||||
);
|
||||
|
|
@ -495,6 +674,19 @@ fn source_line(b: &Board, lx: &Lex) -> String {
|
|||
out
|
||||
}
|
||||
|
||||
/// A clickable join target for a channel: the configured webchat, else an
|
||||
/// `ircs://` URL. Returned already escaped for use in an `href`.
|
||||
fn join_url(b: &Board, channel: &str) -> String {
|
||||
let raw = if !b.webchat.is_empty() {
|
||||
format!("{}{}", b.webchat, channel)
|
||||
} else if !b.server.is_empty() {
|
||||
format!("ircs://{}/{}", b.server, channel.replace('#', "%23"))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
esc(&raw)
|
||||
}
|
||||
|
||||
fn topics_block(b: &Board) -> String {
|
||||
let multi = b.channels.len() > 1;
|
||||
let items: Vec<String> = b
|
||||
|
|
@ -544,7 +736,52 @@ fn stat(num: &str, label: &str) -> String {
|
|||
)
|
||||
}
|
||||
|
||||
fn podium(b: &Board) -> String {
|
||||
fn hist_html(thing: &str, events: &[Event], lx: &Lex) -> String {
|
||||
let key = thing.to_lowercase();
|
||||
let mut evs: Vec<&Event> = events
|
||||
.iter()
|
||||
.filter(|e| e.thing.to_lowercase() == key)
|
||||
.collect();
|
||||
if evs.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
evs.sort_by_key(|e| std::cmp::Reverse(e.t));
|
||||
let items: String = evs
|
||||
.iter()
|
||||
.take(20)
|
||||
.map(|e| {
|
||||
let by = e.by.clone().unwrap_or_else(|| lx.someone.to_string());
|
||||
let chip = if e.delta >= 0 { "++" } else { "--" };
|
||||
let why = match &e.why {
|
||||
Some(w) => format!(" <span class=\"why\">\u{201C}{}\u{201D}</span>", esc(w)),
|
||||
None => String::new(),
|
||||
};
|
||||
format!(
|
||||
"<li class=\"hev\"><span class=\"chip {kl}\">{chip}</span> <b>{}</b>{why} \
|
||||
<time class=\"ago\" data-t=\"{}\"></time></li>",
|
||||
esc(&by),
|
||||
e.t,
|
||||
kl = klass(e.delta),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
format!("<ol class=\"hist\" hidden>{items}</ol>")
|
||||
}
|
||||
|
||||
/// `(class-suffix, title-attr)` marking an element expandable when it has a
|
||||
/// non-empty history block.
|
||||
fn expand_attrs(hist: &str, lx: &Lex) -> (String, String) {
|
||||
if hist.is_empty() {
|
||||
(String::new(), String::new())
|
||||
} else {
|
||||
(
|
||||
" expand".to_string(),
|
||||
format!(" title=\"{}\"", esc(lx.history)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn podium(b: &Board, lx: &Lex) -> String {
|
||||
let top = b.rows.iter().take(3).collect::<Vec<_>>();
|
||||
let mut out = String::from("<ol class=\"podium\">");
|
||||
for (i, r) in top.iter().enumerate() {
|
||||
|
|
@ -565,9 +802,11 @@ fn podium(b: &Board) -> String {
|
|||
(Some(why), None) => format!("<div class=\"why\">\u{201C}{}\u{201D}</div>", esc(why)),
|
||||
_ => String::new(),
|
||||
};
|
||||
let hist = hist_html(&r.thing, &b.events, lx);
|
||||
let (expand, title) = expand_attrs(&hist, lx);
|
||||
out.push_str(&format!(
|
||||
"<li class=\"pillar r{rank}\"><div class=\"crown\">{crown}</div>{}\
|
||||
<div class=\"nick\">{}</div><div class=\"score {}\">{}</div>{spark}{reason}</li>",
|
||||
"<li class=\"pillar r{rank}{expand}\"{title}><div class=\"crown\">{crown}</div>{}\
|
||||
<div class=\"nick\">{}</div><div class=\"score {}\">{}</div>{spark}{reason}{hist}</li>",
|
||||
avatar(&r.thing, "lg"),
|
||||
esc(&r.thing),
|
||||
klass(r.count),
|
||||
|
|
@ -582,8 +821,19 @@ fn ledger(b: &Board, lx: &Lex) -> String {
|
|||
if b.rows.len() <= 3 {
|
||||
return String::new();
|
||||
}
|
||||
let controls = format!(
|
||||
"<div class=\"controls\">\
|
||||
<input id=\"q\" class=\"filter\" type=\"search\" placeholder=\"{ph}\" autocomplete=\"off\" aria-label=\"{ph}\">\
|
||||
<div class=\"sorts\"><button type=\"button\" data-sort=\"score\" class=\"on\">{}</button>\
|
||||
<button type=\"button\" data-sort=\"name\">{}</button>\
|
||||
<button type=\"button\" data-sort=\"active\">{}</button></div></div>",
|
||||
lx.sort_karma,
|
||||
lx.sort_name,
|
||||
lx.sort_active,
|
||||
ph = esc(lx.filter_ph),
|
||||
);
|
||||
let mut out = format!(
|
||||
"<div class=\"eyebrow\">{}</div><ol class=\"list\">",
|
||||
"<div class=\"eyebrow\">{}</div>{controls}<ol class=\"list ledger\">",
|
||||
lx.ledger
|
||||
);
|
||||
for (i, r) in b.rows.iter().enumerate().skip(3) {
|
||||
|
|
@ -598,14 +848,25 @@ fn ledger(b: &Board, lx: &Lex) -> String {
|
|||
(Some(why), None) => format!("<span class=\"why\">\u{201C}{}\u{201D}</span>", esc(why)),
|
||||
_ => String::new(),
|
||||
};
|
||||
let key = r.thing.to_lowercase();
|
||||
let active = b
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| e.thing.to_lowercase() == key)
|
||||
.count();
|
||||
let hist = hist_html(&r.thing, &b.events, lx);
|
||||
let (expand, title) = expand_attrs(&hist, lx);
|
||||
out.push_str(&format!(
|
||||
"<li class=\"row\"><span class=\"rank\">{rank}</span>{}\
|
||||
<div class=\"who\"><div class=\"nick\">{}</div>{reason}</div>\
|
||||
{spark}<span class=\"score {}\">{}</span></li>",
|
||||
avatar(&r.thing, "sm"),
|
||||
esc(&r.thing),
|
||||
klass(r.count),
|
||||
signed(r.count),
|
||||
"<li class=\"row thing{expand}\"{title} data-name=\"{namel}\" data-score=\"{score}\" data-active=\"{active}\">\
|
||||
<span class=\"rank\">{rank}</span>{av}\
|
||||
<div class=\"who\"><div class=\"nick\">{name}</div>{reason}</div>\
|
||||
{spark}<span class=\"score {kl}\">{sg}</span>{hist}</li>",
|
||||
namel = esc(&key),
|
||||
score = r.count,
|
||||
av = avatar(&r.thing, "sm"),
|
||||
name = esc(&r.thing),
|
||||
kl = klass(r.count),
|
||||
sg = signed(r.count),
|
||||
));
|
||||
}
|
||||
out.push_str("</ol>");
|
||||
|
|
@ -693,6 +954,182 @@ fn givers_panel(feed: &[(&str, &Event)], lx: &Lex) -> String {
|
|||
out
|
||||
}
|
||||
|
||||
fn rising(feed: &[(&str, &Event)], now: u64, lx: &Lex) -> String {
|
||||
let week = now.saturating_sub(7 * 86400);
|
||||
let mut m: HashMap<String, (String, i64)> = HashMap::new();
|
||||
for (_, e) in feed {
|
||||
if e.t >= week {
|
||||
let ent = m
|
||||
.entry(e.thing.to_lowercase())
|
||||
.or_insert((e.thing.clone(), 0));
|
||||
ent.1 += e.delta;
|
||||
}
|
||||
}
|
||||
let mut v: Vec<(String, i64)> = m.into_values().filter(|(_, d)| *d != 0).collect();
|
||||
if v.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
v.sort_by(|a, b| {
|
||||
b.1.abs()
|
||||
.cmp(&a.1.abs())
|
||||
.then(a.0.to_lowercase().cmp(&b.0.to_lowercase()))
|
||||
});
|
||||
v.truncate(8);
|
||||
let chips: String = v
|
||||
.iter()
|
||||
.map(|(name, d)| {
|
||||
format!(
|
||||
"<span class=\"tchip {}\">{} <b>{}</b></span>",
|
||||
klass(*d),
|
||||
esc(name),
|
||||
signed(*d)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
format!(
|
||||
"<section class=\"strip fade\"><div class=\"eyebrow\">🔥 {}</div><div class=\"chips\">{chips}</div></section>",
|
||||
lx.rising
|
||||
)
|
||||
}
|
||||
|
||||
fn over_time(feed: &[(&str, &Event)], now: u64, lx: &Lex) -> String {
|
||||
const DAYS: usize = 30;
|
||||
let day = 86400u64;
|
||||
let start = now.saturating_sub((DAYS as u64 - 1) * day);
|
||||
let mut buckets = [0u32; DAYS];
|
||||
for (_, e) in feed {
|
||||
if e.t >= start {
|
||||
let idx = ((e.t - start) / day) as usize;
|
||||
if idx < DAYS {
|
||||
buckets[idx] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let max = *buckets.iter().max().unwrap_or(&0);
|
||||
if max == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let bars: String = buckets
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let h = (*c as f64 / max as f64 * 100.0).round() as u32;
|
||||
format!(
|
||||
"<span class=\"bar\" style=\"height:{}%\" title=\"{c}\"></span>",
|
||||
h.max(3)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let total: u32 = buckets.iter().sum();
|
||||
format!(
|
||||
"<section class=\"chart fade\"><div class=\"eyebrow\">{} \
|
||||
<span class=\"count\">{total} · {DAYS}d</span></div><div class=\"bars\">{bars}</div></section>",
|
||||
lx.over_time
|
||||
)
|
||||
}
|
||||
|
||||
fn most_debated(feed: &[(&str, &Event)], lx: &Lex) -> String {
|
||||
let mut m: HashMap<String, (String, i64, i64)> = HashMap::new();
|
||||
for (_, e) in feed {
|
||||
let ent = m
|
||||
.entry(e.thing.to_lowercase())
|
||||
.or_insert((e.thing.clone(), 0, 0));
|
||||
ent.1 += 1;
|
||||
ent.2 += e.delta;
|
||||
}
|
||||
let mut v: Vec<(String, i64, i64)> = m.into_values().collect();
|
||||
if v.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
v.sort_by(|a, b| {
|
||||
b.1.cmp(&a.1)
|
||||
.then(a.0.to_lowercase().cmp(&b.0.to_lowercase()))
|
||||
});
|
||||
v.truncate(6);
|
||||
let items: String = v
|
||||
.iter()
|
||||
.map(|(name, bumps, net)| {
|
||||
format!(
|
||||
"<li class=\"row\"><div class=\"who\"><div class=\"nick\">{}</div>\
|
||||
<span class=\"why\">{bumps} {}</span></div><span class=\"score {}\">{}</span></li>",
|
||||
esc(name),
|
||||
lx.bumps,
|
||||
klass(*net),
|
||||
signed(*net)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
format!(
|
||||
"<section class=\"panel fade\"><div class=\"eyebrow\">{}</div><ol class=\"list\">{items}</ol></section>",
|
||||
lx.most_debated
|
||||
)
|
||||
}
|
||||
|
||||
fn milestones(feed: &[(&str, &Event)], lx: &Lex) -> String {
|
||||
const LEVELS: &[i64] = &[10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000];
|
||||
let mut evs: Vec<&(&str, &Event)> = feed.iter().collect();
|
||||
evs.sort_by_key(|(_, e)| e.t);
|
||||
let mut cum: HashMap<String, i64> = HashMap::new();
|
||||
let mut hits: Vec<(String, i64, u64)> = Vec::new();
|
||||
for (_, e) in evs {
|
||||
let c = cum.entry(e.thing.to_lowercase()).or_insert(0);
|
||||
let before = *c;
|
||||
*c += e.delta;
|
||||
let after = *c;
|
||||
for &lv in LEVELS {
|
||||
if before < lv && after >= lv {
|
||||
hits.push((e.thing.clone(), lv, e.t));
|
||||
}
|
||||
}
|
||||
}
|
||||
if hits.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
hits.sort_by_key(|(_, _, t)| std::cmp::Reverse(*t));
|
||||
hits.truncate(8);
|
||||
let items: String = hits
|
||||
.iter()
|
||||
.map(|(name, lv, t)| {
|
||||
format!(
|
||||
"<li class=\"hev\">🎉 <b>{}</b> {} <b>{lv}</b> \
|
||||
<time class=\"ago\" data-t=\"{t}\"></time></li>",
|
||||
esc(name),
|
||||
lx.reached
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
format!(
|
||||
"<section class=\"panel fade\"><div class=\"eyebrow\">{}</div><ol class=\"list\">{items}</ol></section>",
|
||||
lx.milestones
|
||||
)
|
||||
}
|
||||
|
||||
fn reasons_wall(feed: &[(&str, &Event)], lx: &Lex) -> String {
|
||||
let items: String = feed
|
||||
.iter()
|
||||
.filter(|(_, e)| e.why.is_some())
|
||||
.take(6)
|
||||
.map(|(_, e)| {
|
||||
let by = e.by.clone().unwrap_or_else(|| lx.someone.to_string());
|
||||
format!(
|
||||
"<li class=\"quote\"><div class=\"q\">\u{201C}{}\u{201D}</div>\
|
||||
<div class=\"qm\"><b>{}</b> \u{2192} <b>{}</b> \
|
||||
<time class=\"ago\" data-t=\"{}\"></time></div></li>",
|
||||
esc(e.why.as_deref().unwrap_or("")),
|
||||
esc(&by),
|
||||
esc(&e.thing),
|
||||
e.t
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if items.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
format!(
|
||||
"<section class=\"panel fade\"><div class=\"eyebrow\">{}</div><ul class=\"quotes\">{items}</ul></section>",
|
||||
lx.reasons_recent
|
||||
)
|
||||
}
|
||||
|
||||
fn footer(epoch: u64, lx: &Lex) -> String {
|
||||
format!(
|
||||
"<footer class=\"fade\">{} · {} \
|
||||
|
|
@ -732,11 +1169,12 @@ fn time_cfg(l: Locale) -> String {
|
|||
}
|
||||
|
||||
const HEAD: &str = r##"<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="__LANG__">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>karma · devtronic</title>
|
||||
__OG__
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ctext y='13' font-size='13'%3E%E2%9C%A8%3C/text%3E%3C/svg%3E">
|
||||
<style>
|
||||
:root{
|
||||
|
|
@ -844,6 +1282,41 @@ border-radius:6px;padding:0 6px;margin-left:2px}
|
|||
.ago{display:block;color:var(--faint);font-size:11.5px;margin-top:2px;font-variant-numeric:tabular-nums}
|
||||
|
||||
footer{text-align:center;color:var(--muted);font-size:12.5px;margin-top:44px;line-height:2}
|
||||
.dot{width:9px;height:9px;border-radius:50%;flex:none}
|
||||
.dot.on{background:var(--up);box-shadow:0 0 0 3px rgba(95,211,159,.16)}
|
||||
.dot.off{background:var(--faint)}
|
||||
.source .chan{font-family:var(--mono);color:var(--gold);font-weight:600}
|
||||
.source .chan:hover{text-decoration:underline}
|
||||
.source .users{font-family:var(--mono);color:var(--faint);font-size:11px}
|
||||
.strip{margin:30px 2px 6px}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:8px}
|
||||
.tchip{font-family:var(--mono);font-size:13px;background:var(--panel);border:1px solid var(--line);border-radius:999px;padding:5px 12px}
|
||||
.tchip.up{color:var(--up)}.tchip.down{color:var(--down)}
|
||||
.tchip b{font-weight:700}
|
||||
.chart{margin:24px 2px 6px}
|
||||
.chart .eyebrow{margin:0 0 10px}
|
||||
.chart .count{margin-left:auto;color:var(--faint);font-family:var(--mono);font-size:11px}
|
||||
.bars{display:flex;align-items:flex-end;gap:3px;height:64px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px}
|
||||
.bar{flex:1;background:linear-gradient(180deg,var(--gold),rgba(245,182,66,.3));border-radius:2px;min-height:2px}
|
||||
.band3{display:grid;grid-template-columns:1fr;gap:16px;margin-top:16px}
|
||||
@media(min-width:720px){.band3{grid-template-columns:1fr 1fr 1fr}}
|
||||
.quotes{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:11px}
|
||||
.quote .q{color:var(--fg);font-size:13.5px;line-height:1.5}
|
||||
.quote .qm{color:var(--muted);font-size:12px;margin-top:2px}
|
||||
.quote .qm b{font-family:var(--mono);font-weight:600}
|
||||
.controls{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:0 2px 12px}
|
||||
.filter{flex:1;min-width:150px;padding:9px 12px;border-radius:10px;border:1px solid var(--line);background:var(--panel);color:var(--fg);font-size:14px}
|
||||
.filter:focus{outline:none;border-color:var(--gold)}
|
||||
.sorts{display:flex;gap:6px}
|
||||
.sorts button{font:inherit;font-size:12px;color:var(--muted);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:7px 11px;cursor:pointer}
|
||||
.sorts button.on{color:var(--bg);background:var(--gold);border-color:var(--gold);font-weight:600}
|
||||
.row.thing,.pillar.expand{cursor:pointer}
|
||||
.row.thing{flex-wrap:wrap}
|
||||
.hist{list-style:none;margin:10px 0 0;padding:10px 0 0;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:6px;flex-basis:100%;width:100%}
|
||||
.pillar .hist{text-align:left}
|
||||
.hev{font-size:12.5px;color:var(--muted);word-break:break-word}
|
||||
.hev b{font-family:var(--mono);color:var(--fg);font-weight:600}
|
||||
.hev .chip{font-size:11px;padding:0 5px}
|
||||
.empty{text-align:center;color:var(--muted);padding:56px 10px}
|
||||
|
||||
@media(max-width:560px){
|
||||
|
|
@ -878,6 +1351,38 @@ return C.pre+d+C.u[6]+C.post;
|
|||
document.querySelectorAll("time.ago[data-t]").forEach(function(t){
|
||||
var v=parseInt(t.getAttribute("data-t"),10);if(v)t.textContent=ago(v);
|
||||
});
|
||||
// click a row/pillar to toggle its history
|
||||
document.querySelectorAll(".row.thing,.pillar.expand").forEach(function(el){
|
||||
el.addEventListener("click",function(e){
|
||||
if(e.target.closest("a"))return;
|
||||
var h=el.querySelector(".hist");if(h)h.hidden=!h.hidden;
|
||||
});
|
||||
});
|
||||
// filter the ledger
|
||||
var q=document.getElementById("q");
|
||||
if(q){q.addEventListener("input",function(){
|
||||
var t=q.value.toLowerCase();
|
||||
document.querySelectorAll("ol.ledger>li.row").forEach(function(li){
|
||||
li.style.display=li.textContent.toLowerCase().indexOf(t)>=0?"":"none";
|
||||
});
|
||||
});}
|
||||
// sort the ledger
|
||||
document.querySelectorAll(".sorts button").forEach(function(btn){
|
||||
btn.addEventListener("click",function(){
|
||||
var key=btn.getAttribute("data-sort");
|
||||
var list=document.querySelector("ol.ledger");if(!list)return;
|
||||
document.querySelectorAll(".sorts button").forEach(function(b){b.classList.remove("on");});
|
||||
btn.classList.add("on");
|
||||
var rows=Array.prototype.slice.call(list.querySelectorAll("li.row"));
|
||||
rows.sort(function(a,b){
|
||||
if(key=="name")return a.getAttribute("data-name").localeCompare(b.getAttribute("data-name"));
|
||||
var an=parseInt(a.getAttribute(key=="active"?"data-active":"data-score"),10);
|
||||
var bn=parseInt(b.getAttribute(key=="active"?"data-active":"data-score"),10);
|
||||
return bn-an;
|
||||
});
|
||||
rows.forEach(function(r){list.appendChild(r);});
|
||||
});
|
||||
});
|
||||
setTimeout(function(){location.reload();},60000);
|
||||
})();"##;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue