use axum::extract::{Query, State};
use axum::response::{IntoResponse, Response};
use axum::Extension;
use laterite_auth::AuthenticatedUser;
use laterite_core::Text;
use serde::{Deserialize, Serialize};
use crate::{nav, render, AdminState, Shell};
#[derive(Clone, Debug)]
pub(crate) struct RecordSource {
pub label: Text,
pub source: String,
pub base_path: String,
pub permission: Option<String>,
}
#[derive(Debug, Serialize)]
pub(crate) struct Hit {
pub label: String,
pub hint: String,
pub href: String,
pub kind: &'static str,
}
#[derive(Deserialize)]
pub(crate) struct PaletteQuery {
#[serde(default)]
q: String,
}
const RECORD_LIMIT: u32 = 8;
const MAX_QUERY: usize = 120;
fn matches(haystack: &str, needle: &str) -> bool {
needle.is_empty() || haystack.to_lowercase().contains(needle)
}
pub(crate) async fn find(
state: &AdminState,
user: &AuthenticatedUser,
shell: &Shell,
q: &str,
) -> Vec<Hit> {
let q = q.trim();
let q = &q.chars().take(MAX_QUERY).collect::<String>().to_lowercase();
let mut hits = Vec::new();
let allows = |code: &str| user.permissions.allows(code);
for node in state.nav.visible(&allows) {
let section = shell.tt(&node.item.label);
if let Some(path) = nav::tab_path(&node) {
if matches(§ion, q) {
hits.push(Hit {
label: section.clone(),
hint: String::new(),
href: path.to_string(),
kind: "page",
});
}
}
for child in &node.children {
let label = shell.tt(&child.item.label);
if let Some(path) = &child.item.path {
if matches(&label, q) {
hits.push(Hit {
label,
hint: section.clone(),
href: path.clone(),
kind: "page",
});
}
}
}
}
for item in state.settings.iter() {
if item.per_operator || !item.permission.as_deref().is_none_or(allows) {
continue;
}
let label = shell.tt(&item.label);
if matches(&label, q) {
hits.push(Hit {
label,
hint: shell.tt(&item.category),
href: item.path(&state.admin_path),
kind: "setting",
});
}
}
if q.is_empty() {
return hits;
}
for source in state.palette.iter() {
if !source.permission.as_deref().is_none_or(allows) {
continue;
}
let Some(reg) = state.pickers.get(&source.source) else {
continue;
};
let found = match reg.source.search(&state.db, q, RECORD_LIMIT).await {
Ok(found) => found,
Err(e) => {
tracing::warn!(source = %source.source, error = %e, "palette search failed");
continue;
}
};
let resource = shell.tt(&source.label);
for node in found {
hits.push(Hit {
label: node.label,
hint: node
.hint
.map(|h| format!("{resource} ยท {h}"))
.unwrap_or_else(|| resource.clone()),
href: format!("{}/{}/edit", source.base_path, node.id),
kind: "record",
});
}
}
hits
}
#[derive(Serialize)]
struct Found {
items: Vec<Hit>,
}
pub(crate) async fn palette(
State(state): State<AdminState>,
Extension(shell): Extension<Shell>,
Extension(user): Extension<AuthenticatedUser>,
Query(query): Query<PaletteQuery>,
headers: axum::http::HeaderMap,
) -> Response {
let items = find(&state, &user, &shell, &query.q).await;
if crate::form::is_htmx(&headers) || headers.get("x-requested-with").is_some() {
return (
[(axum::http::header::CACHE_CONTROL, "no-store")],
axum::Json(Found { items }),
)
.into_response();
}
render(PaletteTemplate {
shell,
q: query.q.trim().chars().take(MAX_QUERY).collect(),
items,
})
}
#[derive(askama::Template)]
#[template(path = "palette.html")]
struct PaletteTemplate {
shell: Shell,
q: String,
items: Vec<Hit>,
}