webs unified

This commit is contained in:
Priec
2026-08-03 13:32:25 +02:00
parent 35d85de556
commit c8afe99d79
59 changed files with 1991 additions and 952 deletions

View File

@@ -121,7 +121,7 @@ pub(crate) async fn load_admin_page(
};
Ok(AdminPageState {
role: authorization.role,
nav: crate::ui::Nav::new(headers, "admin").with_role(authorization.role),
profiles,
selected_profile,
tables,

View File

@@ -8,7 +8,7 @@ pub(crate) struct AdminSelection {
#[derive(Debug)]
pub(crate) struct AdminPageState {
pub role: String,
pub nav: crate::ui::Nav,
pub profiles: Vec<ProfileView>,
pub selected_profile: Option<String>,
pub tables: Vec<TableView>,
@@ -38,6 +38,21 @@ pub(crate) struct ColumnView {
pub quantity_ledger: bool,
}
impl ColumnView {
/// The badge list rendered under each column name.
pub(crate) fn flags(&self) -> Vec<&'static str> {
let mut flags = Vec::new();
if self.primary_key {
flags.push("primary key");
}
flags.push(if self.nullable { "nullable" } else { "required" });
if self.quantity_ledger {
flags.push("quantity ledger");
}
flags
}
}
#[derive(Debug)]
pub(crate) enum LoadError {
Unauthenticated,

View File

@@ -1,124 +1,58 @@
use askama::Template;
use crate::ui::{ErrorPage, Nav, render};
use super::state::AdminPageState;
const ADMIN_CSS: &str = include_str!("../../../../static/admin.css");
/// GET /admin
#[derive(Template)]
#[template(path = "pages/admin/admin/admin.html")]
struct AdminPage<'a> {
nav: Nav,
page: &'a AdminPageState,
}
/// GET /admin/workspace — the HTMX swap for the three-pane browser.
#[derive(Template)]
#[template(path = "pages/admin/admin/workspace.html")]
struct AdminWorkspace<'a> {
page: &'a AdminPageState,
}
pub(crate) fn render_page(page: &AdminPageState) -> String {
format!(
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Admin panel</title><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js\"></script><style>{ADMIN_CSS}</style></head><body><header class=\"topbar\"><div><strong>Komp Accounting</strong><span class=\"role\">{}</span></div><nav><a class=\"active\" href=\"/admin\">Admin</a><a href=\"/\">Analytics</a><form hx-post=\"/logout\" hx-swap=\"none\"><button class=\"link-button\" type=\"submit\">Log out</button></form></nav></header><main><section class=\"heading\"><div><p class=\"eyebrow\">Workspace</p><h1>Admin panel</h1><p>Browse profiles, tables, and their physical columns.</p></div><div class=\"actions\"><a href=\"/admin/tables/new\">Add table</a><a href=\"/admin/logic/new\">Add logic</a><a href=\"/admin/validation/new\">Add validation</a><a href=\"/admin/validation/sets/new\">Add rule</a><a href=\"/admin/import\">Import</a><a href=\"/admin/export\">Export</a></div></section><div id=\"admin-workspace\">{}</div></main></body></html>",
crate::escape_html(&page.role),
render_workspace(page),
)
render(&AdminPage {
nav: page.nav.clone(),
page,
})
}
pub(crate) fn render_workspace(page: &AdminPageState) -> String {
format!(
"<div class=\"workspace\" aria-live=\"polite\">{}{}{}</div>",
render_profiles(page),
render_tables(page),
render_columns(page),
)
render(&AdminWorkspace { page })
}
pub(crate) fn render_error(message: &str) -> String {
format!(
"<div class=\"error-page\"><h1>Admin panel unavailable</h1><p>{}</p><a href=\"/admin\">Try again</a></div>",
crate::escape_html(message),
)
}
fn render_profiles(page: &AdminPageState) -> String {
let items = if page.profiles.is_empty() {
"<p class=\"empty\">No profiles available.</p>".to_string()
} else {
page.profiles
.iter()
.map(|profile| {
let selected = page.selected_profile.as_deref() == Some(profile.name.as_str());
format!(
"<form hx-get=\"/admin/workspace\" hx-target=\"#admin-workspace\" hx-swap=\"innerHTML\"><button class=\"browser-item {}\" type=\"submit\" name=\"profile\" value=\"{}\"><span>{}</span><small>{} tables</small></button></form>",
if selected { "selected" } else { "" },
crate::escape_html(&profile.name),
crate::escape_html(&profile.name),
profile.table_count,
)
})
.collect()
};
format!("<section class=\"pane\"><div class=\"pane-title\"><h2>Profiles</h2><span>{}</span></div><div class=\"pane-list\">{items}</div></section>", page.profiles.len())
}
fn render_tables(page: &AdminPageState) -> String {
let items = if page.selected_profile.is_none() {
"<p class=\"empty\">Select a profile to see its tables.</p>".to_string()
} else if page.tables.is_empty() {
"<p class=\"empty\">This profile has no tables.</p>".to_string()
} else {
page.tables
.iter()
.map(|table| {
let selected = page.selected_table.as_deref() == Some(table.name.as_str());
let dependencies = if table.depends_on.is_empty() {
"No dependencies".to_string()
} else {
format!("Depends on {}", table.depends_on.join(", "))
};
format!(
"<form hx-get=\"/admin/workspace\" hx-target=\"#admin-workspace\" hx-swap=\"innerHTML\"><input type=\"hidden\" name=\"profile\" value=\"{}\"><button class=\"browser-item {}\" type=\"submit\" name=\"table\" value=\"{}\"><span>{}</span><small>{} · display: {}</small></button></form>",
crate::escape_html(page.selected_profile.as_deref().unwrap_or_default()),
if selected { "selected" } else { "" },
crate::escape_html(&table.name),
crate::escape_html(&table.name),
crate::escape_html(&dependencies),
crate::escape_html(&table.row_display_columns.join(", ")),
)
})
.collect()
};
format!("<section class=\"pane\"><div class=\"pane-title\"><h2>Tables</h2><span>{}</span></div><div class=\"pane-list\">{items}</div></section>", page.tables.len())
}
fn render_columns(page: &AdminPageState) -> String {
let items = if page.selected_table.is_none() {
"<p class=\"empty\">Select a table to inspect its columns.</p>".to_string()
} else if page.columns.is_empty() {
"<p class=\"empty\">This table has no visible columns.</p>".to_string()
} else {
page.columns
.iter()
.map(|column| {
let mut flags = Vec::new();
if column.primary_key {
flags.push("primary key");
}
if column.nullable {
flags.push("nullable");
} else {
flags.push("required");
}
if column.quantity_ledger {
flags.push("quantity ledger");
}
format!(
"<div class=\"column\"><span>{}</span><code>{}</code><small>{}</small></div>",
crate::escape_html(&column.name),
crate::escape_html(&column.data_type),
flags.join(" · "),
)
})
.collect()
};
format!("<section class=\"pane\"><div class=\"pane-title\"><h2>Columns</h2><span>{}</span></div><div class=\"pane-list\">{items}</div></section>", page.columns.len())
render(&ErrorPage {
nav: Nav::default(),
heading: "Admin panel unavailable",
message,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pages::admin::admin::state::AdminPageState;
#[test]
fn dashboard_links_every_admin_action() {
let page = AdminPageState {
// The admin page is only reachable with a session, so the navbar
// shows the role badge and the log-out button.
let nav = Nav {
authenticated: true,
role: "admin".to_string(),
active: "admin",
};
let page = AdminPageState {
nav,
profiles: Vec::new(),
selected_profile: None,
tables: Vec::new(),