use super::i18n::{get_translations, Lang, Translations};
/// Application version from Cargo.toml
const VERSION: &str = env!("CARGO_PKG_VERSION");
/// Dark theme layout wrapper for dashboard
pub fn layout_dark(
title: &str,
content: &str,
active_page: Option<&str>,
extra_scripts: &str,
lang: Lang,
) -> String {
let t = get_translations(lang);
format!(
r##"
{} - Nora
{}
"##,
lang.code(),
html_escape(title),
sidebar_dark(active_page, &t),
header_dark(lang),
content,
extra_scripts
)
}
/// Dark theme sidebar
fn sidebar_dark(active_page: Option<&str>, t: &Translations) -> String {
let active = active_page.unwrap_or("");
let docker_icon = r#""#;
let maven_icon = r#""#;
let npm_icon = r#""#;
let cargo_icon = r#""#;
let pypi_icon = r#""#;
// Dashboard label is translated, registry names stay as-is
let dashboard_label = t.nav_dashboard;
let nav_items = [
(
"dashboard",
"/ui/",
dashboard_label,
r#""#,
true,
),
("docker", "/ui/docker", "Docker", docker_icon, false),
("maven", "/ui/maven", "Maven", maven_icon, false),
("npm", "/ui/npm", "npm", npm_icon, false),
("cargo", "/ui/cargo", "Cargo", cargo_icon, false),
("pypi", "/ui/pypi", "PyPI", pypi_icon, false),
];
let nav_html: String = nav_items.iter().map(|(id, href, label, icon_path, is_stroke)| {
let is_active = active == *id;
let active_class = if is_active {
"bg-slate-700 text-white"
} else {
"text-slate-300 hover:bg-slate-700 hover:text-white"
};
let (fill_attr, stroke_attr) = if *is_stroke {
("none", r#" stroke="currentColor""#)
} else {
("currentColor", "")
};
format!(r##"
{}
"##, href, active_class, fill_attr, stroke_attr, icon_path, label)
}).collect();
format!(
r#"
"#,
nav_html,
t.nav_registries,
VERSION
)
}
/// Dark theme header with language switcher
fn header_dark(lang: Lang) -> String {
let (en_class, ru_class) = match lang {
Lang::En => ("text-white font-semibold", "text-slate-400 hover:text-slate-200"),
Lang::Ru => ("text-slate-400 hover:text-slate-200", "text-white font-semibold"),
};
format!(r##"
"##, en_class, ru_class)
}
/// Render global stats row (5-column grid)
pub fn render_global_stats(
downloads: u64,
uploads: u64,
artifacts: u64,
cache_hit_percent: f64,
storage_bytes: u64,
lang: Lang,
) -> String {
let t = get_translations(lang);
format!(
r##"
"##,
t.stat_downloads, downloads,
t.stat_uploads, uploads,
t.stat_artifacts, artifacts,
t.stat_cache_hit, cache_hit_percent,
t.stat_storage, format_size(storage_bytes)
)
}
/// Render registry card with extended metrics
pub fn render_registry_card(
name: &str,
icon_path: &str,
artifact_count: usize,
downloads: u64,
uploads: u64,
size_bytes: u64,
href: &str,
t: &Translations,
) -> String {
format!(
r##"
{}
{}
"##,
href,
name.to_lowercase(),
icon_path,
t.active,
name,
t.artifacts, artifact_count,
t.size, format_size(size_bytes),
t.downloads, downloads,
t.uploads, uploads
)
}
/// Render mount points table
pub fn render_mount_points_table(mount_points: &[(String, String, Option)], t: &Translations) -> String {
let rows: String = mount_points
.iter()
.map(|(registry, mount_path, proxy)| {
let proxy_display = proxy.as_deref().unwrap_or("-");
format!(
r##"
| {} |
{} |
{} |
"##,
registry, mount_path, proxy_display
)
})
.collect();
format!(
r##"
"##,
t.mount_points,
t.registry,
t.mount_path,
t.proxy_upstream,
rows
)
}
/// Render a single activity log row
pub fn render_activity_row(
timestamp: &str,
action: &str,
artifact: &str,
registry: &str,
source: &str,
) -> String {
let action_color = match action {
"PULL" => "text-blue-400",
"PUSH" => "text-green-400",
"CACHE" => "text-yellow-400",
"PROXY" => "text-purple-400",
_ => "text-slate-400",
};
format!(
r##"
| {} |
{} |
{} |
{} |
{} |
"##,
timestamp,
action_color,
action,
html_escape(artifact),
registry,
source
)
}
/// Render the activity log container
pub fn render_activity_log(rows: &str, t: &Translations) -> String {
format!(
r##"
"##,
t.recent_activity,
t.last_n_events,
t.time,
t.action,
t.artifact,
t.registry,
t.source,
rows
)
}
/// Render the polling script for auto-refresh
pub fn render_polling_script() -> String {
r##"
"##.to_string()
}
/// Sidebar navigation component (light theme, unused)
#[allow(dead_code)]
fn sidebar(active_page: Option<&str>) -> String {
let active = active_page.unwrap_or("");
// SVG icon paths for registries (Simple Icons style)
let docker_icon = r#""#;
let maven_icon = r#""#;
let npm_icon = r#""#;
let cargo_icon = r#""#;
let pypi_icon = r#""#;
let nav_items = [
(
"dashboard",
"/ui/",
"Dashboard",
r#""#,
true, // stroke icon
),
("docker", "/ui/docker", "Docker", docker_icon, false),
("maven", "/ui/maven", "Maven", maven_icon, false),
("npm", "/ui/npm", "npm", npm_icon, false),
("cargo", "/ui/cargo", "Cargo", cargo_icon, false),
("pypi", "/ui/pypi", "PyPI", pypi_icon, false),
];
let nav_html: String = nav_items.iter().map(|(id, href, label, icon_path, is_stroke)| {
let is_active = active == *id;
let active_class = if is_active {
"bg-slate-700 text-white"
} else {
"text-slate-300 hover:bg-slate-700 hover:text-white"
};
// SVG attributes differ for stroke vs fill icons
let (fill_attr, stroke_attr) = if *is_stroke {
("none", r#" stroke="currentColor""#)
} else {
("currentColor", "")
};
format!(r##"
{}
"##, href, active_class, fill_attr, stroke_attr, icon_path, label)
}).collect();
format!(
r#"
"#,
super::logo::LOGO_BASE64,
nav_html,
VERSION
)
}
/// Header component (light theme, unused)
#[allow(dead_code)]
fn header() -> String {
r##"
"##.to_string()
}
/// SVG icon definitions for registries (exported for use in templates)
pub mod icons {
pub const DOCKER: &str = r#""#;
pub const MAVEN: &str = r#""#;
pub const NPM: &str = r#""#;
pub const CARGO: &str = r#""#;
pub const PYPI: &str = r#""#;
}
/// Stat card for dashboard with SVG icon (used in light theme pages)
#[allow(dead_code)]
pub fn stat_card(name: &str, icon_path: &str, count: usize, href: &str, unit: &str) -> String {
format!(
r##"
ACTIVE
{}
{}
{}
"##,
href, icon_path, name, count, unit
)
}
/// Format file size in human-readable format
pub fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
format!("{} B", bytes)
}
}
/// Escape HTML special characters
pub fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
/// Render the "bragging" footer with NORA stats
pub fn render_bragging_footer(lang: Lang) -> String {
let t = get_translations(lang);
format!(r##"
"##,
t.built_for_speed,
t.docker_image,
t.cold_start,
t.memory,
t.registries_count,
t.multi_arch,
t.zero_config,
t.tagline
)
}
/// Format Unix timestamp as relative time
pub fn format_timestamp(ts: u64) -> String {
if ts == 0 {
return "N/A".to_string();
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if now < ts {
return "just now".to_string();
}
let diff = now - ts;
if diff < 60 {
"just now".to_string()
} else if diff < 3600 {
let mins = diff / 60;
format!("{} min{} ago", mins, if mins == 1 { "" } else { "s" })
} else if diff < 86400 {
let hours = diff / 3600;
format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" })
} else if diff < 604800 {
let days = diff / 86400;
format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
} else if diff < 2592000 {
let weeks = diff / 604800;
format!("{} week{} ago", weeks, if weeks == 1 { "" } else { "s" })
} else {
let months = diff / 2592000;
format!("{} month{} ago", months, if months == 1 { "" } else { "s" })
}
}